-
Notifications
You must be signed in to change notification settings - Fork 213
Possible solution for #107 - implicit constructors #105
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
What about something like an // package:http
Future<Response> get<T into Uri>(T url, {Map<String, String> headers}) => _client.get(url into Uri, headers: headers);
// Uri
class Uri {
// ...
from String uri => Uri.parse(uri);
}
// User defined class
class MyUri {
// ..
into Uri => Uri.parse("http://example.com/$path");
} Every type implements Interface conflicts won't be an issue, since the most specific into conversion is selected for a type, just like virtual methods. In fact, into could be implemented as mangled methods (at least initially). Good questions: |
cc @filiph |
This is implicit conversion. It does not have to be a constructor, but I can see how it would be convenient to allow the operation to act like a constructor in some cases. I fear that it might be too limiting if only the class itself can define conversion to it. I might want to define a conversion in the source type, not the target type, because that's the one I'm in control of. There are three general issues here:
The class C {
final value;
C(this.value);
// Implicit cast from instance of C to Foo. Inherited by sub-classes.
// (Then it's part of the interface!)
Foo operator cast() => Foo(value);
// Implicit cast from C to Baz.
static Baz operator cast(C c) => Baz(c.value);
// Implicit cast from Foo to C.
static C operator cast(Foo foo) => C(foo.value);
// Implicit cast from Bar to C (using constructor behavior to create the C)
static C.operator cast(Bar bar) : this.value = bar.value;
}
// Implicit cast from Baz to C, when declaration is in scope.
// (So, not needed if we have static extension methods, then just declare it on C or Baz).
C operator cast(Baz baz) => C(baz.value); The, when you need to assign an expression of type
If any step finds more than one viable cast, and none of them are most specific, make it a compile-time error. |
// precursor. | ||
``` | ||
|
||
* When evaluating an assignment to type `T`, if the provided value `P` is |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What happens if P
has a static type of dynamic
? It seems like this could easily end up causing issues with tree shaking (not being able to treeshake any implicit constructor if dynamic is ever passed to a function that takes that type).
Maybe we could say this only happens at compile time, so you have to explicitly cast to get the implicit conversion? That would also cause some weird issues potentially but might also make it faster?
Making it only a compile-time thing would also mean it can be implemented in the FE only most likely.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh I should have read the other comments first haha, @lrhn brought up essentially the same question.
Question: are any of the extended versions not already expressible if we have just the basic feature (implicit constructor) + scoped extension methods? It seems to me that all of the static and top level casts can just be added as instance casts in a scoped extension method for the appropriate type. e.g. // Implicit cast from instance of C to Foo. Inherited by sub-classes.
// (Then it's part of the interface!)
// Foo operator cast() => Foo(value);
extend Foo with {
implicit Foo(C c) => Foo(c);
}
// static Baz operator cast(C c) => Baz(c.value);
extend Baz with {
implicit Baz(C c) => Baz(c.value);
} etc? |
I agree w/ @leafpetersen . The flexibility offered by @lrhn's proposal is interesting, but it scares me, too. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd say land this for iteration, discussion, and future reference (no promises... :). Can you:
- File an issue stating the problem you're trying to solve (e.g. "APIs that can take one of two different types (one of which is constructed from the other) are very common in Flutter and hard to express")
- File an issue for discussion of your solution (this) referencing the first issue
- move this to working/#of the first issue - implicit_constructors/
- land this, then add a link to your doc to the first comment of the second issue filed.
Note all:
Cheers! |
@leafpetersen It's quite possible that we can get everything with static extension cast operations. Static extension methods are "inheritable" (they're not virtual, but subtypes do get them unless they override the member with something else on a subtype). However, constructors are used on a type, not on an object, so they differ from static extension methods. That will allow externally adding constructors to a class (maybe even generative ones, as long as they are redirecting). However, Constructors are never inherited (the typing doesn't make sense), and we don't want to check an unbounded sub-type tree for implicit constructors, so I'm not sure using constructors is the way to go. I'd prefer to search for the conversion on the source type because it makes sense to check for casts in its (limited) superinterfaces So, we could also define cast operators as instance methods on the source type, and allow adding or overriding them as static extension methods. extend class String with {
operator Uri() => Uri.parse(this); // C++ cast operator syntax, for example.
} All our other operators are instance methods working on the operator. The reason this feels slighlty odd is that tl;dr: I don't think constructors are the way to go, and they don't fit well with static extension methods. |
yeah, agreed. FWIW, C# allows conversion operators to be defined in either direction, you can do it on the same type. Here's an example from the C# spec (Conversion operators section): using System;
public struct Digit
{
byte value;
public Digit(byte value) {
if (value < 0 || value > 9) throw new ArgumentException();
this.value = value;
}
public static implicit operator byte(Digit d) {
return d.value;
}
public static explicit operator Digit(byte b) {
return new Digit(b);
}
} (The example is illustrating that implicit conversions should only be used for cases where they don't cause data loss or throw exceptions, otherwise an explicit conversion should be used. But it also illustrates how edit: in Dart that would probably look more like: class Uri {
...
// Add implicit conversions from String <-> Uri in both directions.
static operator String(Uri uri) => uri.ToString();
static operator Uri(String str) => Uri.parse(str);
} |
One thought: Anything we do here will impact profoundly with overloading if we ever have any intention of supporting overloading by type. I have read numerous horror stories from C++, C#, and Swift about the interactions between overload resolution and implicit conversions. Just implicit conversions is easy because you only have a single linear path of conversions your are trying to solve. But overloading introduces argument lists which means the solution space you are exploring to select conversions and overloads is combinatorial. Putting that aside... I really like Lasse's suggestion to make conversions instead methods on the source object. It's natural in that you must have some object you are converting from in the first place. It composes very nicely with extension methods. The latter also lets you do things like define conversions from specific types, not just classes. For example, I could define a conversion from I'm not worried that this forces you to put the conversion on the "wrong" type in that you end up declaring it on the more fundamental type (String) instead of the more domain-specific (Uri). I think that presumes a mental model where extension methods are monkey-patching or interfering with the extended type in some way. But that's not my model. Extension methods are syntactically called from the type, but aren't related to it in any other way. They don't break its encapsulation, access its internal state, define invariants for it, or override behavior. In other words, they can't break anything the original author of the class expects to be true. So I think of them as being "outside" of the type. Pragmatically, a nice thing about always defining conversions as instance/extension methods on the source type is that it lets us reuse the existing rules for method collisions to also cover colliding conversions. If a class can define multiple conversions to different types (which I think they should support), then a syntax like C++'s that uses the destination type name as a name is probably a good hint that those conversions don't collide. If we do something like
This is true, but that feature is also older than extension methods. If they had extension methods first, I wonder if they would have still felt the need to support defining conversions on both the source or destination type. |
C# overload resolution is NP-hard (Eric Lippert's 2007 blog post about it) but it's due to lambda expressions. I don't recall offhand what the worst case is with only implicit conversions+overloads. In practice, the worst case basically never comes up. (Besides, we already have a much more common NP-complete problem in the Dart ecosystem... package resolution 😉)
fair point! It does simplify the search for implicit conversions to only look at the types involved, but you're right, defining them via extension methods would be quite nice too: operator String cast(this Uri uri) => uri.ToString();
operator Uri cast(this String str) => Uri.parse(str); |
I don't understand this comment, can you elaborate? I don't understand why there's any searching at all.
Assuming You do immediately get into the question of overloading if you want to have multiple implicit constructors, but I'm not sure any of the options avoid something like this (again, assuming you want to support multiple implicit constructors). |
So something like: class A {
operator int() => 3;
}
class B {
operator double() => 3;
}
class C extends A with B {
}
num x = new A(); // Produces 3?
num x = new C(); // Static error?
class D extends A {
operator num() => 3.0;
}
int x = new D(); // Produces 3?
num x = new D(); // Produces 3.0?
num x = new D() as A; // Produces 3? It feels unpleasantly obscure to me on first read. The criticism that I hear of implicit conversions in other languages is that it's hard to understand what the compiler is doing. I'm not sure that throwing virtual dispatch into the mix really improves that situation. |
Is virtual dispatch involved in your example, or is it more about how subtyping on the destination type gets involved? My (simple) mental model for this is that each conversion operator has a different name — the name of the destination type. So in C, there is no overriding or collision. Then the question is whether either of those matches when the destination type is class A {
operator int() => 1;
}
class B {
operator double() => 2.0;
}
class C extends A with B {}
num x = new A(); // Error. A has no "operator num()".
num x = new C(); // Error. C has no "operator num()".
class D extends A {
operator num() => 3.0;
}
int x = new D(); // 1.
num x = new D(); // 3.0
num x = new D() as A; // Error. A has no "operator num()". You're probably right that virtual dispatch doesn't add much value. One, perhaps terrible idea: allow defining implicit conversions only as extension methods on the source type. That sidesteps inheritance and overriding issues. |
First: My bad, I meant to say "unbounded subtype hierarchy". The question is that if |
For the virtual override, I think the biggest issue would be not knowing whether a conversion is virtual. num x = new A(); // Produces 3?
num x = new C(); // Static error? I'd be fine with that result, yes. The former checks the static type int x = new D(); // Produces 3?
num x = new D(); // Produces 3.0?
num x = new D() as A; // Produces 3? The first one I'm OK with, there are conversions to There is no virtual dispatch here, only signature based overloading. It's not the virtuality that's a problem, it's the overloading plus inheritance which makes it possible to use an object at a super-type where not all of the operations are (statically know to be) available. The overloading is necessary, we will want to be able to convert |
No.
I need some justification for this claim. Constructors in Dart are non-virtual. All this is doing is giving you a convenient way to automatically call a constructor. From the perspective of the use cases in mind, it seems to me that searching the subtype hierarchy would be an explicit anti-feature. It's also anti-modular. Moving an additional subtype of So I don't get the argument. Virtual would be bad, non-virtual has no issues. What's the problem? |
Some rough ideas. CC @leafpetersen @efortuna @jakemac53