Multiple upper bounds
I need to implement a solution using generics that implements 2 interfaces, but as far as I can tell, generics in dart only supports 1 upper bound?
The contents of the 2 interfaces is not really relevant, and what I'm trying to do, is construct a class that can process this in generic form.
What I want is something like this:
abstract class RawRepresentable<T> {
T get rawValue;
}
extension EnumByRawValue<T, E extends Enum & RawRepresentable<T>> on E {
// compile error
}
I know this is possible in both Typescript and Java, but I'm fairly new at Dart. Anyone know?
The problem with multiple upper bounds is that it effectively introduces intersection types.
That raises a lot of questions that need to be answered, in a satisfactory and consistent way, before such a feature can be added.
- If I declare a function
T foo<T extends Foo & Bar>(T value) { ... }, what can I do tovalueinside the body? - If both
FooandBardeclare a method namedbaz, can I call it? With which signature? - If I call it as
var z = foo(something as dynamic);, what will the implicit downcast fromdynamicbe to? - What is the declared type of
z?
The answer to those questions are very likely going to either imply that the language has intersection types in general, or they'll imply inconsistent and surprising behavior. Or just plain useless behavior.
(I don't have the answers. I'd love to see a consistent and useful definition which doesn't imply general intersection types, but I haven't found one myself.)
Personally I would assume this would behave just like as if T was a class that implemented both Foo and Bar in some way.
Those conflicts are then handled the exact same way as they would inside of T.
if two methods have the same name but a different signature, its simply not possible to implement them both.
T would make all properties of both Foo and Bar available. They cannot conflict as that would be a compile time error.
This wouldnt be like a Union type, where T can be either Foo and Bar, it has to be both just like if we were to make a real class that is both Foo and Bar.
That is also why I would suggest the syntax T extends Foo extends Bar instead of &.
We might want to implement actual union types at some point, which then would be more prone to using & and |.
Disallowing conflicts in interface is a reasonable answer to the first two items. If you know that there exists a subclass implementing both, which has a compatible override of both signatures, then you'll just have to extend that instead. We can use the same rules for when interfaces are compatible that we do for classes inheriting multiple interfaces, and not having an override themselves.
The last two items are tougher, because that's not just what you can do with the object, but about whether intersection types exists outside of type variable bounds.
If they do, then the language just has intersection types. It's no longer about type parameter bounds.
If not, ... what is happening in var z = foo(something as dynamic);? Will the type of z be Foo, Bar or dynamic? Which type check(s) will happen on the argument to foo?
It's incredibly hard to constrain something to just type variables, because type variables also occur as types in the inputs and outputs of the thing that declares the variable. They leak. Even if every instantiation is guaranteed to have a concrete type, type inference only works if we can find that type.
(So an answer could be that var z = foo(something as dynamic); fails to compile because we cannot infer a concrete type argument to foo, and intersection bounds cannot be instantiated-to-bound.)
I guess that did answer all my questions, so would that be a valid design?
A type parameter can have multiple bounds. If there is more than one bound:
- No bound must be a function type,
dynamic,Neverorvoid. (Because we need to combine their interfaces, and those classes have important behaviors separate from their interfaces. We probably can allow all of these, but two function types are unlikely to have compatiblecallmethods, and any other non-Nevertype is not going to have any shared non-Neversubtype with a function type. Thedynamic,Neverandvoidtypes just need special casing, that it's probably not worth giving them.)- No two bounds may implement the same interface with different type arguments. (Cannot implement both
Future<int>andFuture<bool>, just like a class cannot.) AFutureOr<T>counts as implementingFuture<T>for this purpose.- At compile time, the type variable's type has the same members as the combined interface of the bounds (same way super-interfaces are combined in classes). If the interfaces have incompatible members (where the class would need to declare a member), the bounds are incompatible, and a compile-timer error occurs.
- The type variable's type is a subtype of all its bounds.
- The type variable's type is nullable if ~~any~~all of its bounds are nullable.
- The order of the bounds does not matter (a parameter of
<X extends Foo & Bar>is equivalent to<X extends Bar & Foo>. (It matters because function subtyping requires having the same bounds.)- If a type parameter with multiple bounds is instantiated to bounds, it is a compile-time error. (The parameter can still be super-bounded.)
- Type arguments passed to the parameter must be subtypes of all bounds.
- If
Xis a type variable with multiple bounds, flatten(X) ... well, let's get back to that one. (I'm pretty sure it can be done).
(One advantage of having a type variable with multiple bounds, instead of a proper intersection type, is that type variables don't have any subtypes (other then Never). One less thing to worry about.)
I'm not very well-versed in language design so pardon my ignorance.
In lieu of concrete intersection types, would it be possible to leverage record types for this? Wherein a type variable bound is only used for compile-time checking and the concrete type of the type variable is something like T = (Foo, Bar) for T extends Foo & Bar and T = (Foo?, Bar?) for T extends Foo | Bar?
This would seem to address all of the points, although I'm curious where this falls apart and if this violates any soundness in the current type system.
If I declare a function T foo<T extends Foo & Bar>(T value) { ... }, what can I do to value inside the body?
value would have type (Foo, Bar) and would need to be de-structured before use.
If both Foo and Bar declare a method named baz, can I call it? With which signature?
baz could be called independently on each or restricted, as mentioned above, such that it is a compile-time error.
If I call it as var z = foo(something as dynamic);, what will the implicit downcast from dynamic be to?
This could effectively be var z = foo((something as Foo, something as Bar));
What is the declared type of z?
z would have type (Foo, Bar)
Using a pair of values (possibly optional) solves the problem of assigning two types to one value, by assigning two types to two values. What it loses is having only one value. And that's really the most important part.
It's not a great API. If you have to pass an int | String to a function, it's signature would be:
void foo((int?, String?) value) ...
Nothing prevents you from passing a pair with two values, or zero, and you will have to call it as foo((null, "a")) or foo((1, null)). Not that good ergonomics.
You'd be better off by writing a proper union type class:
abstract class Union<S, T> {
factory Union.first(S value) = _UnionFirst<S>;
factory Union.second(S value) = _UnionSecond<T>;
S? get first;
T? get second;
}
class _UnionFirst<S> implements Union<S, Never> {
final S first;
_UnionFirst(this.first);
Never get second => throw StateError("No second");
}
class _UnionSecond<T> implements Union<Never, T> {
final T second;
_UnionSecond(this.second);
Never get first => throw StateError("No first");
}
Then you can do void foo(Union<int, String> value) ... and call it as foo(Union.first(1)).
The intersection type is the same, foo((List<int>, Queue<int>) listQueue) ..., where nothing ensures that you pass the same value as both pair-values. Calling it will be foo((listQueue, listQueue)).
If I declare a function
T foo<T extends Foo & Bar>(T value) { ... }, what can I do tovalueinside the body?
– All getters, setters and methods from the types Foo or Bar can be directly called from the method body, with a single exception. The single exception refers to methods, getters and setters with the same name, but different return types. To call these methods the class must first be casted to Foo or Bar to avoid intersecting the return types (int & String is Never) The return type is not useful when it's typed as Never. Also, the class can be casted to Foo & Baz, but Baz must not have the name conflict mentioned above (more on this after the next topic).
class C<T extends A & B> {
T t;
void test() {
(t as A).foo(); // OK
(t as B).foo(); // OK
}
}
If both
FooandBardeclare a method namedbaz, can I call it? With which signature?
– Yes, baz can be called with an union of both signatures. Methods with different parameters, but the same name, must receive a union of these types and then handle the type in the method body. The IDE must show an error if the Qux class doesn't implement void baz(A | B argument). The implementation of the baz method is up to who's writing the class.
class Qux implements Foo, Bar {
void baz(A | B argument) {
switch (argument) {
A a => _bazA(argument); // OK
B b => _baB(argument); // OK
}
}
void _bazA(A argument) {}
void _bazB(B argument) {}
}
Note: when the return types are different, the return type will be an union. Nonetheless, it will still be useful, because of the cast that must happen before the method is called. This behavior should not happen frequently.
If I call it as
var z = foo(something as dynamic);, what will the implicit downcast from dynamic be to?
– Generic type parameters will be able to receive multiple bounds, then method parameters must be able to do that as well! 😀 The type T in T foo<T extends Foo & Bar>(T value) { ... } must be Foo & Bar unless better inferred by the type system as Qux. foo can be represented without generics as following:
Foo & Bar foo(Foo & Bar value) { ... }
What is the declared type of
z?
– Finally, considering foo returns Foo & Bar, the declared type of z in final z = foo(argument) must be Foo & Bar.
@Wdestroier This is a perfectly good description of actual intersection and union types (which is issue #1222 ). If Dart had those, this issue would not be needed. What is being explored here is whether it could be possible to have multiple bounds on a type variable, without introducing general intersection types - and union types, because those two go hand-in-hand. (That was also mentioned in #1152, as part of a more complex discussion, and got lost. Which should be a lesson about not rising multiple problems in the same issue.)
Of course this is going to sound a bit silly of me, but Java and C# solved this problem somehow. And at least C# also has dynamic. So from my point of view, this isn't completely new territory. Is there a particular reason why this is hard to achieve in Dart?
I also find myself in situations where I would profit greatly from intersection types. Just think about the Iterable interface and how inflexible it is. For example, conceptually an iterable might know its own length. But it is exactly this "might" that can't be expressed in Dart. So either the length property gets implemented inefficiently or it just throws at runtime. Or you implement all possible combinations of interfaces (Iterable could have many more optional features like Bidirectional) as their own interface (effectively the power set), which would be insane. In C# I can express this easily.
In essence: Intersection types would be really, really neat.
This issue is not about intersection types but rather specific generic type restrictions. You might be looking for #83.
This issue is not about intersection types but rather specific generic type restrictions. You might be looking for #83.
Sorry, I was specifically referring to @lrhn's post.
The problem with multiple upper bounds is that it effectively introduces intersection types.
And I tried to explain why I don't think that
I would assume this would behave just like as if T was a class that implemented both Foo and Bar in some way.
is true.
@lrhn it can start with the simplest case WITHOUT support for intersecting types. e.g.:
mixin A { bool get a; }
mixin B { bool get b; }
class AB with A,B { bool a; bool b; }
//this function only accepts types that implement both A and B
//where A and B MUST be mixins.
//proposed syntax, can be simplified of course.
void myFunc<T with A with B /*, other generic arguments*/>(T input) { /**/ }
myFunc<AB>() //works!
void myFunc<T with String with int>(T input) { /**/ } //disallowed since String, int are not mixins.
this follows the same rules as mixins, so it should be easy to implement.
That's still an intersection type.
We have a type T where we know that it implements A and B, but we don't know how.
That's the difference between a nominal type which implements two interfaces, and we can point to that class to say how, and the unnamed intersection of the two interfaces, where all we know is that whatever actual type ends up here, it has found a way to implement both interfaces. That's an intersection type. That's what intersection types are.
Using mixins makes no difference, it's not a restriction. You can implement (non-base) mixins, so they're just interfaces with a different word, and more capabilities.
But a type variable bounded by an intersection can be possible, without introducing intersection types in general. Because we do have a name for the type, even if it's just the type variable name. It's not a completely structural intersection type.
If we allow a type variable to have multiple bounds, then any member access on that type must work on the combined interface. That's a concept we already have, so that's not a problem. If the combined interface cannot find a combined signature for a member, that means you cannot access that member. (Unlike a class implementing the same interfaces, which has to provide a valid signature for conflicted superinterface members.) You can always up-cast to one of the superinterface and use the member there.
The type variable's type would otherwise be assignable to any bound, and be a subtype of either.
We might have to make some restrictions to avoid the bounds implementing different instantiations of the same interface. A type variable cannot be bounded by both Future<A> and Future<B>, where neither is a subtype of the other, or if it can, you're not allowed to await that type.
Which means that every place on the specification where we check whether a type implements a generic interface, and if so, with which instantiation, it'll be an error to use a type variable with multiple bounds that do not have single instantiation we can use.
That's a s complication, but it should only be an issue locally in the scope of that type variable, which declared the incompatible bounds to begin with.
Any type which implements both would be a valid type argument to that type parameter, which can either be an actual subtype of both, or another type variable which is a subtype of all the bounds.
If we do allow that, then we could potentially also allow multiple intersections due to promotion. But probably not. And you can only promote a value with the type variable type to a subtype of all the bounds.
I have run into the same issue, and without intersection types, types start to leak to other parts of the code base:
abstract interface class CsvSerializable {
// ...
}
abstract interface class Dated {
DateTime get dateTime;
}
// A manual non intersection type:
abstract interface class CsvSerializableDatedComparable<T>
implements CsvSerializable, Dated, Comparable<T> {}
class InterestingClass<T extends CsvSerializableDatedComparable<T>> {
// Has methods which use the above three interfaces.
}
But a type variable bounded by an intersection can be possible, without introducing intersection types in general. Because we do have a name for the type, even if it's just the type variable name. It's not a completely structural intersection type.
This is what C# does as far as I understand it. It allows multiple bounds on type parameters, but there's no notion of an interface type. It's just that:
- When doing member lookup on a target whose type is a type parameter, the interfaces of all bounds are used to look for the member.
- When assigning a value of a type parameter type T to some other type D, the assignment is valid if D is an interface type and at least one of the bounds of T is that interface.
- Since the only place where intersection-like types come into play is type parameters, the only place where you could potentially see them behave like intersections is when assigning one type parameter to another. But that's already disallowed since obviously they could be instantiated with non-assignable types.
In other words, since the intersection is encapsulated inside a type parameter, which behaves pretty much like a nominal type, it works out without too much complexity.
Honestly, in Dart, I think we're closer to already having intersection types than C# is because we already support promoted types. I haven't wanted multiple bounds in Dart very often, but it does come up sometimes.
What Dart differs from C#, and would therefore have issues with its approach, is mainly in not having overloading.
In C#, if one supertype has an int foo(int) method, and the other has a String foo() method, then the intersection just has both, because it is possible to have multiple methods with the same name.
In Dart, you can only have one foo method signature, so we have to either
- figure out an intersection type for the method signatures
- disallow accessing a method with conflicting method signatures (it's only OK if one is a subtype of all the other, then GLB is trivial). Access includes tearoff, although we could allow that if the context type is a supertype of at least one signatures.
- or have a special typing rule for invoking such, which is allowed if the individual arguments are accepted by at least one of the signatures, and it's only allowed in a context which accepts at least one of the return types. Tear-off also needs a context type to work, which is a similar pointwise supertype.
We will be able to assign a type variable to another type variable type, if one is a bound of the other, which means one can have more supertypes than the other.
We can promote a variable with a type variable type. We might want to be able to promote more than once then.
Assume B1 <: A1, B2 <: A2 and
void foo<T extends A1 & A2>(T v) {
if (v is B1 && v is B2) {
// Promoted to B1, not B2,
// Or promoted to B1&B2 ?
// The latter would be nice.
}
}
Promotion to an intersection introduces structural intersection types, which was what we tried to avoid. It might work because the promotion gets erased in most cases where it would matter.
What Dart differs from C#, and would therefore have issues with its approach, is mainly in not having overloading.
True, sometimes it's hard to tell if we've really avoided that much complexity in not having overloading. It avoids a lot of problems, but it creates other ones too, like having to synthesize merged members in superinterfaces.
sometimes I wonder how effective it would be to implement explicit overloading, e.g. two mixins/classes can have two methods with the same name and different signatures, but implementers can separate them
mixin A {
void m();
}
mixin B {
int m(String param);
}
class AB with A,B {
void A.m() { /*method to be called only when target class is A*/ }
int B.m(String param) { /*method to be called only when target class is B*/ }
List<String> m() { /*method to be called only when target class is AB*/ }
}
which are called via casting:
final AB ab = AB();
final a = ab as A;
final b = ab as B;
ab.m() // targets List<String> m()
a.m("") // targets void A.m()
b.m() // targets int B.m(String param)
two mixins/classes can have two methods with the same name and different signatures, but implementers can separate them
That sounds to me sort of like explicit interface implementation in C#.
I think if we were to do something like this, we'd also want normal overloading too. The latter is really useful in API design beyond just disambiguating when you have member collisions from superinterfaces.
I've not yet read the full extent of this issue thread, so if I'm repeating something I apologize, but I'd like to share one use case for this.
I'm developing a Flutter app with Drift, a package for dealing with SQLite databases that uses build_runner.
Drift's approach involves creating non-abstract classes that extend Table, which generates code to manage the data.
In my project, I have several tables with at least two common primary keys (PKs). These tables extend a simple abstract table class containing these columns.
In my DAOs, I consistently implement a set of specific, similar methods across these tables. To streamline this, I created a base class with a generic type T that extends the abstract table.
However, only the specific classes generated by build_runner are accepted to work with the DAOs. These classes include a mixin with two generics: the class it extends and the type class generated by Drift to handle database operations.
To ensure compatibility, I specify that T extends TableInfo<T, dynamic>. Additionally, T must extend my abstract class to include the common columns.
My workaround involves creating another abstract class with the dependencies correctly arranged. However, every time I use build_runner, I must manually add the implements keyword to all generated classes that meet these constraints.
I also asked for help in the Community Discord, and someone (julemand101) suggested creating my own mixin that meets these constraints. However, I can't do this because TableInfo (Drift mixin) already uses the on keyword and therefore cannot be added in another mixin class.
I'm asking here something similar to the "extended" classes issue (akin to Rust Traits, as mentioned in the comments).
Why exactly are we avoiding structural intersection types? It seems to me like a strictly useful feature.
It is very useful for mixins, and of course normal promotions.
after all, if I do if (thing is Foo && thing is Bar) then why shouldn't it promote to both?
I mean, the language itself already sort of supports it doesn't it? i occasionally see something & something in type hints, so i don't really see the issue.
Syntax wise, there arent any operators in use for types, so we could totally use & just like untagged unions could use |
Intersections would allow us to get rid of classes who's only purpose is to combine the unrelated types/mixins for generics.
As it is, mixins are... not as useful as they can be since we cant ask for multiple in a "flat" way without some kind of weird wrapper thing.
example with intersection types
mixin Foo {
Thing foo();
}
mixin Bar {
Other bar(That that);
}
Something run(SomeContext context, Foo & Bar it) {
final Thing thing = it.foo();
final that = context.of(thing);
final Other other = it.bar(that);
return makeSomething(other);
}
To do the same thing now, i would have to require two parameters for the same object, which might break the contract if the caller passes different objects in. asserts can catch that, but its a terrible API.
For the record, Foo and Bar could have dramatically different usages elsewhere and different interactions with other types
right now, i would have to create a:
class FooBar implements Foo, Bar {
final Foo _foo;
final Bar _bar;
FooBar(this._foo, this._bar) : assert(identical(foo, bar));
Thing foo() => _foo.foo();
Other bar(That that) => _bar.bar(that);
// incredibly painful for larger interfaces.
}
Something run(SomeContext context, FooBar it) {...}
// perhaps a need for an extension so we could do `foo.with(bar)` which has the result of doing `it.with(it)` if users don't know about the interface, or cant implement it.
which is a terrible API and does not scale.
sure, specific classes could implement FooBar, but they might just... not know to, so the wrapper thing is needed.
FooBar results in just missing any class that "happens" to implement both, which is a damn shame. too much burden on the developer.
AND! Any future functions that want any combinations later cant just ask for the relevant interfaces. so either do runtime checks, or use combinator-forwarding classes what we wouldnt need if the language had supported it.
I have had to just... avoid mixins and such apis entirely because you just cant make this work at all right now.
Structural intersection types come with a usability issue: what is the interface of the type? And can it be reified as a type separate from the type variable bound.
Assignability from an intersection type is fine. It's assignable to both types.
If both types declare a foo method, then we know that the intersection has a foo method, but if the two intersected types have different signatures, we need to decide which signature the foo method of the intersection type has.
If one has an optional named bar parameter, and the other has an optional named baz parameter, then does the intersection method have both?
The options are:
- interface members can have intersection types. In that case you can call it as either type, but you cannot pass both a
barand abazargument. - intersection members have a combined signature (the same signature we use if you implement two interfaces). In that case, it can fail to have a signature.
In the latter case, failing to have a signature can be avoided by giving the parameters union types. But then we introduce union types to the language too, and those come with even more usability issues, especially if they can be introduced by type inference (fx you won't get a type error as easily).
Overstuffing general intersection types usually implies obtrusive general union types. Which is a very complex thing to do to your type system.
The current intersection types in the language are always intersecting with a type parameter and a type that is a subtype of the bound of that type variable. That means that there is only one interface involved. They're also very transient, existing only at compile time and only as the type of a promoted variable.
If we have general intersection types, not just multiple bounds on a type variable, you can also have a variable that has an intersection type as declared type. Then assignability to the type starts to matter, and testing against the type.
That is, you should be able to do x is (T1 & T2), x as (T1 & T2) and that check probably can't be much more efficient than just doing x is T1 && x is T2.
A type is assignable to (T1 & T2) x; if it's assignable to both T1 and to T2. Subtypes are easy.
Coercions are more "fun". Should a callable object be coerced if assigned to Null & Function?. (Is that type the same type as Null or is it just a type that happens to have only Null and Never as its only subtypes? Do we canonoicalize? If so, when?)
Should we coerce only when a type is coercible to both types?
What does dynamic & num mean? It's clearly a subtype of num and a supertype of num, but it's not num.
Does it have dynamic invocation? Is it implicitly down-cast-able?
How does a Type object behave for an intersection type.
Take:
typedef typeof<T> = T;
void main() {
var dan = typeof<dynamic & num>;
print(dan == num); // True or false?
print(dan == typeof<Object? & num>); // True or false?
print(dan == typeof<void & num>); // True or false?
}
The static type system and the dynamic type system both need to address these questions.
(The static type system will likely not canonicalize anything, it only cares about subtype/supertype relations. The runtime type system, with access to Type objects and their == is where the language introduce canonicalization through the Norm function on types.)
Where can we have a failed signature? I don't know of any case where dart allows you to have conflicting types of the same member name anywhere. By definition, any member would have to be typeof A.foo & typeof B.foo for A & B
that does mean that all optional parameters need to be included, as any object implementing both with have them. and also the return types for both are intersected, as any implementor would return an object that fulfills both.
which would be part of the rules for intersecting function types
also, any intersection where either is a supertype of the other, can be normalized to be the subtype, as it by definition implements the other already.
that means dynamic & num is useless, as its just num (also its sealed, so we can do some fun logic for whats allowed there)
Object? & num is also num, because think about it, this represents any object that implements both Object? and num, which is only fulfilled by num.
Any intersection that we can determine is impossible (such as a final class and a non-supertype (which (the supertype) is normalized away anyway)) is effectively Never, as you could never assign an object to it
Null & Foo? only seems to get past this on account of the T? union type, which is simply resolved to Null, as Foo? is, in fact, a supertype of Null
I thought about dynamic & AnotherType in the past, would be nice in some cases, then I wouldn't need to cast back and forth when all I know is a single superinterface.
I commented in this issue before that we should cast the variable to a specific type, but I disagree now. Calling a different method based on the current static type is very error-prone, at least the way I described it (t as A).foo().
The options are:
- interface members can have intersection types. In that case you can call it as either type, but you cannot pass both a bar and a baz argument.
- intersection members have a combined signature (the same signature we use if you implement two interfaces). In that case, it can fail to have a signature.
Couldn't another option exist for an early feature? Compatible with existing Dart constraints. For example, a class trying to implement D and E would show a compile-time error saying "Superinterfaces don't have a valid override for 'bar': D.bar (int Function()), E.bar (String function)" for:
abstract class D {
int bar();
}
abstract class E {
String bar();
}
I know a class could implement D and E and return Never (lets assume D & E doesn't work in this case). Most cases would show an error saying "Missing concrete implementations of ..." (D & E works). Would an algorithm to check if D & E don't have conflicts be too complex? Another detail are covariant parameters.
The same rule applies to mixins. For example, a class with A and B would show an error at compile-time saying "B.log(...) isn't a valid override of A.log(...)" (lets assume the case below doesn't work):
mixin A {
log(String name) => print(name);
}
mixin B {
log(int age) => print(age);
}
class C with A, B {}
If A and B received String as parameter, then both the C class and the upper bound would be valid. I'm possibly overlooking details and I'm still in favor of union types and overloads. The idea is to "decrease" this feature's scope and allow it to be implemented earlier.
Let's get concrete.
Take this wonderful extended diamond property class hierarchy:
// abc.dart
abstract final class _A {
void _bar() {
print("This is happening!");
}
}
abstract final class A1 implements _A {
factory A1() = _D;
A1 foo(C1 _, C1 _);
}
abstract final class A2 implements _A {
factory A2() = _D;
A2 foo(C2 _, C2 _);
}
abstract final class B1 implements A1, A2 {
factory B1() = _D;
B1 foo(B1 _, B1 _);
}
abstract final class B2 implements A1, A2 {
factory B2() = _D;
B2 foo(B2 _, B2 _);
}
abstract final class C1 implements B1, B2 {
factory C1() = _D;
C1 foo(A1 _, A1 _);
}
abstract final class C2 implements B1, B2 {
factory C2() = _D;
C2 foo(A2 _, A2 _);
}
void callBs(void Function<T extends B1&B2>(T value) function) {
function<_D>(_D());
}
// Secret, safe!
final class _D extends _A implements C1, C2 {
_D foo(_A o1, _A o2) {
_bar();
return this;
}
}
and then in another library that knows nothing about _D or _A:
import "abc.dart";
void main() {
callBs(bar);
}
void bar<T extends B1&B2>(T value) {
var v1 = value.foo(B1(), B1()); // #1
var v2 = value.foo(B2(), B2()); // #2
var v3 = value.foo(B1(), B2()); // #3
var v4 = value.foo(C1(), C2()); // #4
var f = value.foo; // #5
baz(value.foo(B1(), B2())); // #6
}
void baz<T extends B1 & B2>(T _){
print(T);
}
Types being final is a red herring. They can still have subtypes. Never is a valid type and return type of concrete functions. Edge cases aren't the problem, the base case is problematic enough.
So here:
- #1
- Is it allowed? (It should be, it's a valid invocation of
B1.fooandTis-aB1.) - What is the static type of
v1? Is itB1or is itB1&B2? Either is possible.- If the signature of
value.fooisB1 Function(B1,B1) & B2 Function(B2, B2), then the compiler can say that you're invoking the former and the return type is `B1. Or it can intersect all the return types anyway. But then we have introduce general intersection types, not just multiple bounds. - If the signature of
value.foois(B1&B2) Function(B1|B2, B1|B2), we have introduced union types. - Or we can disallow the assignment, because inference cannot find a single unambiguous non-intersection type
that
v1can have, just like we don't reify type-variable intersection types today. (The compiler knows the type of the expression isB1&B2, so it could soundly choose eitherB1orB2, but it would be arbitrary. What if it chose the other one tomorrow because someone changed the bound toextends B2&B1? That would be bad!)
- If the signature of
- Is it allowed? (It should be, it's a valid invocation of
- #2: Symmetric with #1
- #3: Is it allowed?
- If the signature is an intersection of function signatures, then the argument list is not valid for either of the types.
- The compiler could still treat the parameters as effective union types, by saying that an argument list is allowed for an intersection of function types if each argument is allowed by either of the intersected types.
- #4: Allowed.
- It's allowed by both function types. If #1 would derive a return type of
B1, then likely so would this, or it could equally likely beB2, but then it's clearly order dependent, which is bad. So the return type should beB1&B2. Only way to stay sane. Or disallowv4being inferred, you have to write a type.
- It's allowed by both function types. If #1 would derive a return type of
- #5: What is the type of
f? This goes to the crux of the issue. We can hand-wave "method signatures" and pretend that a type is one of two classes, so the method is one of two method signatures, and keep the superposition alive until we call it, checking each argument against all signatures. This assignment to a single value with a single type collapses that. We need one type for that value. * We can say that it's not allowed. There is no non-ambiguous non-intersection type that inference can infer forf. * We can inferB1 Function(B1, B1), which is fragile and arbitrary. * We can allowfto be an intersection type(B1 Function(B1, B1)&B2 Function(B2, B2)), and again that introduces general intersection types. * We can allowfto be(B1&B2 Function(B1|B2, B1|B2)and introduce union types too. - #6: Allowed? If so, what is the type argument to
baz? * If the type argument isB1&B2, we have general intersection types again, reified at runtime. * If it'sB1orB2, it's arbitrary. * Or it too can be disallowed.
In this issue, which is only about multiple bounds on type variables, I'd probably go with not allowing general intersection types, so all five variables above are inference failures. (I'd make compilation fail rather than infer dynamic, since this is completely new code, we don't have to repeat the mistakes of the past.)
That is:
-
Intersection types exist in the static type system only,
(T1&T2)is a static type. -
A type variable can have an intersection bound, given as
extends T1&..&Tn. (A type variable can be promoted to any static type, also an intersection type, soX&(T1&T2)is allowed too.) -
An expression can have an intersection type, so
(T1&T2)is a type in the static type system. -
An intersection type is the greatest subtype of the types of the intersection (it's a subtype of both, and if any type is a subtype of both, it's also a subtype of the intersection).
-
Any type check can promote, not just those of subtypes, but if it's not a subtype or supertype of the current type, it promotes to an intersection type.
A1 a = A1(); if (a is B1) if (a is B2) { /* a: B1&B2, promotion chain: A1, B1, B1&B2 */ }- Promoting
Xwith boundB1toTwhereTis not a subtype ofB1givesX&(B1&T), becauseB1&Tis a subtype ofB. (and also to not forget the bound.) - Or it can just be
X&Tas a completely plain intersection, no special-casing needed for type variables. (I don't believe that, if nothing else we need to preserve that the erasure is the type variable in those cases, because there will never be a most specific type when intersection type variables, unless the other type is a type variable which extends the first,X&Y. And then it doesn't need the intersection, it can just promote toY.)
- Promoting
-
Intersection types are not reified as types of variables, parameters, or function return types. As today, if an intersection type is used to infer a reified type, it's intersection-erased. If it's a promoted type variable, then it erases to the type variable like today. If not, and there is a single type of the intersection which is a proper subtype of the other types, then it erase to that. (We can special case union types so
int? & numerases toint. It's the distributive law:(int | null) & num == (int & num) | (null & num) =erases-to=> int | Never.) Otherwise, there is no single most specific type in the intersection, it's a compile-time error. -
That doesn't change that
X extends List<B1>&List<B2>is a type. I can't be sure if that causes problems where we can usually assume that no type implements the same generic interface in more than one way. It's probably safe, as long as the intersection keeps them apart, intersections are between unrelated types, so it shouldn't be a problem. (Or maybe this is what we need to remove that rule, if it's possible to be aList<B1 & B2>. But that requires reified intersection types.) -
Intersection types are not reified as type arguments. #6 above is a compile-time error, it cannot pass
B1&B2, and it cannot non-arbitrarily choose either. -
It's a compile-time error if a member is accessed on an intersection type, and two types of the intersection have different kinds of members with that base name (one has a getter and/or setter, the other a method).
- That does guarantee that the intersection type is empty, no object instance can implement both signatures.
- We don't promise to check for that unless the member is accessed. (But the linter could check that any intersection is not incompatible.)
-
Otherwise the member signatures of intersection types are intersections of member signatures. If an intersected type has no member of a given name, it can just be ignored.
-
Extensions apply to an intersection type normally, if none of the intersected types have a member with the same base name. That likely means that a lot of them don't work, since an intersection type is not a valid type argument to a generic extension. (But see https://github.com/dart-lang/sdk/issues/56028, this is already a mess with existing intersection types.)
-
An intersection of function types (incl. setters and operators) can be called if each argument can be passed to at least one of the function types. The type of the call is the intersection of the return types. (Implicit parameter union type behavior, without reifying the union types.)
- The context type of the argument expression would be the UP of the parameter types of the individual function types.
Then it's a compile-time error if the argument expression type, after coercion to that context type, is not a subtype of
at least one of the corresponding parameters of one of the function types of the intersection.
(If there is only one function, UP is trivial, if one is a proper supertype of all the others, then that is chosen,
but if there are multiple maximal types, which are mutual subtypes, then one of them will be chosen somewhat
arbitrarily. That's usually only something that happens around
void/dynamic/Object?, and those are special-cased to be less arbitrary, so it's usually not a problem. It's not a new problem in any case.)
- The context type of the argument expression would be the UP of the parameter types of the individual function types.
Then it's a compile-time error if the argument expression type, after coercion to that context type, is not a subtype of
at least one of the corresponding parameters of one of the function types of the intersection.
(If there is only one function, UP is trivial, if one is a proper supertype of all the others, then that is chosen,
but if there are multiple maximal types, which are mutual subtypes, then one of them will be chosen somewhat
arbitrarily. That's usually only something that happens around
-
We'll have to define NORM, UP, etc. on intersection types.
-
NORM: Would likely move nullable inwards and drop common supertypes. Maybe something like:
- NORM((
T1&T2)?) = **NORM**(T1? &T2`?) -- Union distribution - NORM(FutureOr<
T1&T2>) = **NORM**(FutureOr<T1> & FutureOr<T2`>) -- Union distribution - NORM(
T1&T2): LetN1be NORM(T1),N2be NORM(T2).- If
N1<:Objectand notN2<:Object, letN2be NonNull(N2) - If
N2<:Objectand notN1<:Object, letN1be NonNull(N2) - If
N1is a proper supertype ofN2, thenN1. - If
N2is a proper supertype ofN1thenN2. - If
N1andN2are both top types, then MoreTop(N1,N2`). - (If they are mutual subtypes, then Up(
N1,N2), to choose as arbitrarily as Up does anyway?) - Otherwise (
N1&N2).
- If
- NORM((
-
UP is probably point-wise:
- **UP(
T1&T2,T3) = UP(T1,T3) & UP(T2,T3) - **UP(
T1,T2&T3) = UP(T1,T2) & UP(T1,T3)
That can blow up quadratically.
- **UP(
-
DOWN (lower bound) should probably not introduce an intersection. (Although it's exactly where you would want it.)
- DOWN(
T1,T2):- If
T1is a proper subtype ofT2thenT1. - If
T2is a proper subtype ofT1thenT2. - (If they are mutual subtypes, then .. .something)
- otherwise (
T1&T2).
- If
Lower bounds are rare, it's not going to break a lot. I think.
- DOWN(
-
-
We have to handle coercions and assignability from and to intersection types. Coercing to:
- An expression with type
dynamicin a context type ofT1&T2(can a context type ofT1&T2occur?) would have to do(((e) as T1) as T2)as coercion, doing two type checks. - An expression
eof typeT Function<T>(T)in a non-generic function context type ofB1 Function(B1) & B2 Function(B2)should ... probably just fail. It cannot reify a<B1&B2>type argument, and any other type won't be valid. (The big question is how it would get to that conclusion). - An expression of type
F, a callable class type, with a context ofK, would probably do.callinsertion ifK"is a function type", which it is if any of its types are function types. (And if some are functions types and others are not, it'll be an empty type.)
Coercing from:
- An expression of type
(T1&T2)is a subtype ofT1andT2. It's definitely assignable to those. - Assignability other than by subtyping is from dynamic, from generic function to non-generic and from callable class to function. Only applies if it's not a subtype.
- We could say that an intersection type is assignable if any of its types are assignable.
But we probably don't want
(dynamic & int)to do implicit downcasts. So, makedynamicspecial: An intersection is dynamically assignable if every type of it isdynamic. (Do not assume that we do type normalization at compile-time, so(dynamic & dynamic)can exist. - For
.callinsertion: If the context type of an expression with an intersection static type is a function type orFunction(including an intersection type which contains at least one function type orFunction) and at least one type of the intersection static type is a callable type (where we would insert.callfor it otherwise), then insert the.calltear-off. (Dart doesn't allow implicit extension.calltear-off, so there won't be extensioncallmethods involved.) The static type of that member access is the intersection of the types ofcallmembers in the original intersected types, with nothing contributed by types which do not have acallmember. - For generic instantiation (done after
.calltear-off), an intersection of generic function types is empty if the type parameters and bounds are not "the same" (all mutual subtypes with proper substitutions). So make it an error if they are not compatible, and otherwise just solve for one of them and add that type argument.
- An expression with type
-
If intersections are not reified, we don't have to worry about
Typeobject equality. That's a relief.
It could probably work. It would be limited, type inference failures would likely happen often enough to be annoying. It spreads throughout the static type system, it's not just limited to type variables. Any expression can have an intersection type, not just those typed as type parameters (because calling members can give intersection types back that are not related to a type parameter.)
The most immediate alternative to that is to intersection-erase the return type of member invocations, which will fail if there is no most specific type among the return types. Then intersection types would truly only be in type parameters, and the only thing you can do with an object typed as that type parameter is to call methods on it that all the types don't disagree on, and up-cast it to any of the intersection types.)
That would be a slight generalization of the current "promoted type variable-only intersection type". That has some sharp edges, but if we don't want to break things, those around erasure will probably stay.
If that still throws too often, one other alternative would be: Make intersections ordered. That is B1&B2 is not the same as B2&B1. They are mutual subtypes, so it's only about inference where we treat it like a promotion chain (because that's how a promoted type variable like X&B1&B2 would have arisen). Then we use the last type in the a non-type-variable intersection when we need to erase.
class B1 {
B1 get foo;
}
class B2 {
B2 get foo;
}
var baz<T extends A1>(T a) {
if (a is B1) {
// a : T&B1
if (a is B2) {
// a : T&B1&B2
var v = a.foo; // type `B1&B2` erases to B2. Not because it's better, but because it was checked most recently.
}
}
}
It would make fewer cases throw, but make some choices semi-arbitrary. But still predictable and controllable.
Which also means changing <X extends B1&B2> to <X extends B2&B1> can change behavior, but likely only inside the class.
(This is not a complete specification, it's a suggestion of the kind of details that a specification would need to address. There are more type functions to handle, and special cases. If nothing else, it needs to be checked that there is no surprising interaction with, fx, async/await and intersection types. And all the other features.)
If we do want to avoid general type intersections, at least for this issue, we could keep it super simple.
We don't intersect.
Instead, it's about as useful as existing union types on their own - that is, it isn't.
It then becomes mostly an API thing, and authors using the feature can freely assign to specific values like:
void fn<T extends A&B>(T val) {
final A a = val;
final B b = val;
...
}
Which is entirely free to do. T only receives the interface if it's upper bounds, which just reuses existing implementation.
In effect, we get discount intersection types, only it's in such a way that we can add more to it later for real intersection types.
A step up; we only intersect trivial members, like those that don't exist in both, and add rules for intersection over time, slowly refining it, instead of needing to do the whole thing all at once.
In effect, give nothing but the code of this issue, and expand it's capabilities and limits as we figure them out.
I'm not sure if we can do the same thing with union types... Something to think about later I suppose.
My opinion is that we should introduce intersection types and untagged union types both, but there's no reason we can't take a baby step in that direction.
I have a problem understanding what <T extends A&B> even means.
Does it mean that T implements both A and B?
But... how? Are we talking about duck typing here? That is, some class C can satisfy A & B without ever mentioning A or B in its definition, but by simply providing implementations of all methods of A and B?
I assume that's not the case. Class C has to explicitly declare C implements A, B, right?
But to implement A and B, class C has to provide implementations of all methods of A and B. If any signature conflict arises between methods, then C is responsible for resolving it, which it sometimes can, or sometimes cannot do, like in this example
class A {
String toPrettyString() => 'A';
}
class B {
String toPrettyString(int x) => 'B$x';
}
class C implements A,B {
String toPrettyString(int x)=> 'C'; // error: ... is not a valid override of ...
}
It's impossible for C to implement both A and B. This eliminates one of the problems: "what happens if the parameter is declared with a type A&B, and there's a conflict of names? Which method to call?". This problem doesn't exist.
Or maybe T extends A & B means something different from "T implements both A and B"? Then what is it?
(@lrhn: I'm getting a strange error message in the above program. I can't copy from dartpad (a bug?), but you will see it)
what <T extends A&B> even means.
That T is a type parameter, and the only type arguments that can be passed to T are types that are subtypes of both A and B.
Some intersection types are always going to be empty (meaning they contain no instances, not that they contain no subtypes, they likely contain lots of subtypes all the way down to Never, but they're all empty intersection types).
The example here can be solved:
class C implements A,B {
String toPrettyString([int? x])=> 'C'; // would be valid override.
}
An incompatible example would be
class A {
int get x = 42;
}
class B {
int x() => 42;
}
void foo<T extends A&B>(T value) {
... value.x ... // Can't touch this!
}
There will never be a concrete subtype of both A and B, because that would have to have a member that is both a getter and a method.
Another example is <DateTime & DateTime Function()>. No user class can implement a function type, no function can implement an interface type.
The type system has no problem with the type A & B existing, it trivially does if A and B exists. The problem comes when it tries to assign method signatures to A&B. The fact that it can't is reason enough to say that you can't use that member. It's not necessarily enough to say that you can't use the type.
The error message for your example (you can copy it if you right-click and avoid the browser context menu covering the "copy text" option) is:
'C.toPrettyString' ('String Function(int)') isn't a valid override of 'A.toPrettyString' ('String Function()').
That's correct, it's not.
I stand corrected about toPrettyString example, but it doesn't change the conclusion: all potential conflicts have to be resolved by C.
There are two cases:
- the conflict cannot be resolved : then there's nothing to talk about
- the conflict can be resolved:
then while handling
value.xcompiler generates a normal virtual call - by looking forxin the virtual table of the actual parameter (in this case it's a "value" parameter).
To counter this, you have to provide an example of the class C that correctly implements both A and B defined as
class A {
int get x = 42;
}
class B {
int x() => 42;
}
Does such an implementation exist?
'C.toPrettyString' ('String Function(int)') isn't a valid override of 'A.toPrettyString' ('String Function()').
I don't understand where 'String Function()' comes from, sorry. I'm sure there's a reason for this, but I expected to see something simpler: 'C.toPrettyString(int) isn't a valid override of A.toPrettyString()`.
class A {
int get x = 42;
}
class B {
int x() => 42;
}
There is no way to implement both. It is not possible to have a member that's both a method and a getter.
The member is toPrettyString, and
String Function() is the type of that method. Ie; it returns String and has no arguments.
If the compiler can determine that some method cannot be implemented by both A and B, then the type A&B is at best "partially valid". But dart has no concept of "partially valid" types. E.g. if some class C claims to implement interface X, but the implementation omits some method from X, or provides an incompatible implementation, then the whole thing is deemed invalid - no matter if said method is ever invoked by the program. In line with this, the "impossible A & B" will become an invalid type.
This is consistent with what "implement the interface" means. No?
For the sake of an argument, let's suppose that the compiler doesn't complain in this scenario:
class A {
int get x = 42;
}
class B {
int x() => 42;
}
void foo<T extends A&B>(T value) {
// NO COMPLAINT unless you use value.x
}
Now suppose the caller calls this method passing a parameter of type C
class C {
//...
}
foo(C());
No matter how class C is defined, the compiler cannot allow such a call, because for sure, the parameter is not compatible with the type A&B. This is a hard error. The compiler won't look into the implementation of foo, it never does. Whether x is, or is not, accessed there is immaterial.