Skip to content
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

Multiple upper bounds #2709

Open
anyoptional opened this issue Dec 10, 2022 · 54 comments
Open

Multiple upper bounds #2709

anyoptional opened this issue Dec 10, 2022 · 54 comments
Labels
request Requests to resolve a particular developer problem

Comments

@anyoptional
Copy link

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?

@lrhn
Copy link
Member

lrhn commented Dec 11, 2022

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 to value inside the body?
  • If both Foo and Bar declare a method named baz, can I call it? With which signature?
  • If I call it as var z = foo(something as dynamic);, what will the implicit downcast from dynamic be 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.)

@clragon
Copy link

clragon commented Dec 11, 2022

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 |.

@lrhn
Copy link
Member

lrhn commented Dec 11, 2022

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, Never or void. (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 compatible call methods, and any other non-Never type is not going to have any shared non-Never subtype with a function type. The dynamic, Never and void types 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> and Future<bool>, just like a class cannot.) A FutureOr<T> counts as implementing Future<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 anyall 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 X is 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.)

@dnys1
Copy link

dnys1 commented Dec 15, 2022

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)

@lrhn
Copy link
Member

lrhn commented Dec 15, 2022

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)).

@Wdestroier
Copy link

If I declare a function T foo<T extends Foo & Bar>(T value) { ... }, what can I do to value inside 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 Foo and Bar declare a method named baz, 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.

@lrhn
Copy link
Member

lrhn commented Dec 17, 2022

@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.)

@MarvinHannott
Copy link

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.

@clragon
Copy link

clragon commented Sep 11, 2023

This issue is not about intersection types but rather specific generic type restrictions.
You might be looking for #83.

@MarvinHannott
Copy link

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.

@ahmednfwela
Copy link

@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.

@lrhn
Copy link
Member

lrhn commented Sep 27, 2023

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.

@dcharkes
Copy link
Contributor

dcharkes commented Nov 9, 2023

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.
}

@munificent
Copy link
Member

munificent commented Mar 8, 2024

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.

@lrhn
Copy link
Member

lrhn commented Mar 9, 2024

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.

@munificent
Copy link
Member

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.

@ahmednfwela
Copy link

ahmednfwela commented Mar 11, 2024

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) 

@munificent
Copy link
Member

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.

@FMorschel
Copy link

FMorschel commented Aug 1, 2024

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).

@TekExplorer
Copy link

TekExplorer commented Oct 30, 2024

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.

@lrhn
Copy link
Member

lrhn commented Oct 30, 2024

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 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.

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.)

@TekExplorer
Copy link

TekExplorer commented Nov 2, 2024

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

@TekExplorer
Copy link

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.

@tatumizer
Copy link

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)

@lrhn
Copy link
Member

lrhn commented Nov 2, 2024

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.

@tatumizer
Copy link

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:

  1. the conflict cannot be resolved : then there's nothing to talk about
  2. the conflict can be resolved:
    then while handling value.x compiler generates a normal virtual call - by looking for x in 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()`.

@TekExplorer
Copy link

TekExplorer commented Nov 2, 2024

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.

@tatumizer
Copy link

tatumizer commented Nov 2, 2024

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.

@lrhn
Copy link
Member

lrhn commented Nov 3, 2024

Correct, but foo(C()) is invalid for the same reason void bar<T extends A>(T value) {...}; class C { /*...*/ } void main() { bar(C()); } is: The argument type doesn't allow the compiler to infer a type argument T to bar which satisfies the bound A. (It can try to instantiate to bounds or use Never, but that wouldn't allow Object as argument. And if the bound had been T extends List<T>, instantiate to bounds wouldn't be work either.)

The same thing happens for foo(C()), the C doesn't implement A or B so C is not a valid type argument, and inference can't find a better alternative, and it cannot instantiate to bounds or use Never either, so you have a type error. Nothing new, and nothing related to the incompatibility of the interfaces of A and B (other than being absolutely certain that no C will implement both A and B).

Unless the compiler guarantees that no valid argument can ever reach foo, and that guarantee is built into the language (and maybe not even then), it should still analyze the body of foo by itself, and it should definitely do that analysis if A & B isn't guaranteed empty.
If foo had been written as void foo<T extends A&B>(T? value) { .... } then it can be called as foo<Never>(null), so the body is not unreachable because of the type parameter, but because of the actual parameter being an empty type. That's not different from void qux(Never v) { ...}, but we don't omit functions like qux from type inference.

I think it's easier to just do the analysis, and bail out if an incompatibility is found which would preclude continuing, like reading x and needing a type for it. I don't want to specify which types are definitely incompatible in the language specification, so that intersections are definitely empty, and a function with a definitely empty argument need not be analyzed.
Then void foo<T extends A&B>(T? value) { value ?? throw "Not null!"; ... } would still need to be analyzed, and we still need to do something useful there.

That is: Don't bother detecting if a type is empty or not. It's not worth it.

@tatumizer
Copy link

I think I get it. Please verify that my understanding is correct.
Consider

class A {
  String toPrettyString() => 'A';
}
class B {
  String toPrettyString(int x) => 'B$x';
}
foo(A & B arg) {
   arg.toPrettyString(); // works
   arg.toPrettyString(10); // works, too
}

In other words, while compiling the code for foo, the compiler allows EVERY signature of toPrettyString as defined in either A or B. The compiler doesn't care if it's feasible for a class to implement both. The signatures might be totally incompatible with each other - the compiler doesn't care.
Correct?

Then the question: is there any difference between A&B and A|B (union, as discussed elsewhere), from the point of view of foo? It seems that if foo was declared as foo(A | B arg), then the same treatment would apply (every signature of toPrettyString would be supported).
True? False?

@mmcdon20
Copy link

mmcdon20 commented Nov 3, 2024

class A {
  String toPrettyString() => 'A';
}
class B {
  String toPrettyString(int x) => 'B$x';
}
foo(A & B arg) {
   arg.toPrettyString(); // works
   arg.toPrettyString(10); // works, too
}

Then the question: is there any difference between A&B and A|B (union, as discussed elsewhere), from the point of view of foo? It seems that if foo was declared as foo(A | B arg), then the same treatment would apply (every signature of toPrettyString would be supported). True? False?

Well A | B arg means that arg is either an A or a B.

You can't simply do

foo(A | B arg) {
  arg.toPrettyString(); // error
  arg.toPrettyString(10); // error
}

Because if arg happens to be an A then how can you call arg.toPrettyString(10);? If arg happens to be a B then how can you call arg.toPrettyString();?

In order to call the method from A you would have to first check that it is an A, which could be done using is operator and type promotion.

foo(A | B arg) {
  if (arg is A) {
    arg.toPrettyString(); // works
  }
  if (arg is B) {
    arg.toPrettyString(10); // works
  }
}

This is similar to how null safety works, a variable of type A? is essentially the same as a variable of type A | Null. You can't call the method from A until you check that either it is an A or that it isn't null, which allows the variable to be promoted to type A.

@tatumizer
Copy link

Oh, my bad 😄
But don't you find a bit strange that for A & B the compiler is going to allow both forms, no questions asked?
Even if the classes are defined as

class A {
  String toPrettyString(Foo foo, Bar bar) => 'A';
}
class B {
  String toPrettyString(int x) => 'B$x';
}
foo(A & B arg) {
   arg.toPrettyString(Foo(), Bar()); // fine
   arg.toPrettyString(10); // fine, too
}

@mmcdon20
Copy link

mmcdon20 commented Nov 3, 2024

Oh, my bad 😄 But don't you find a bit strange that for A & B the compiler is going to allow both forms, no questions asked? Even if the classes are defined as

class A {
  String toPrettyString(Foo foo, Bar bar) => 'A';
}
class B {
  String toPrettyString(int x) => 'B$x';
}
foo(A & B arg) {
   arg.toPrettyString(Foo(), Bar()); // fine
   arg.toPrettyString(10); // fine, too
}

Well if you think of A & B as a type which is simultaneously an A and a B, then it makes some sense that it would have all of the methods defined in A and B. The part that is a bit strange is that due to some other restrictions in the language you can not actually create a type that satisfies A & B in this instance, so its sort of like defining foo(Never arg) {...}.

@tatumizer
Copy link

Exactly. The compiler has to compute a "common denominator" or these two signatures. Not sure about "Never" - if it comes to Never, it's probably better to flag the A & B as an error. As a bonus for computing a common denominator, you would be able to tear off the method, see its signature in IDE, etc. Otherwise, it will look like a hack.

@mmcdon20
Copy link

mmcdon20 commented Nov 4, 2024

Exactly. The compiler has to compute a "common denominator" or these two signatures.
As a bonus for computing a common denominator, you would be able to tear off the method, see its signature in IDE, etc. Otherwise, it will look like a hack.

I'm not sure what you mean about common denominator. My understanding is that if you call foo(x) the compiler would check x at that point to see if it is an A and a B and if it is not both, then a compiler error would occur. I'm not sure the compiler would need to do anything additional beyond that.

Not sure about "Never" - if it comes to Never, it's probably better to flag the A & B as an error.

What I meant was A & B in your example is similar to Never in the sense that you can not create an instance of something that satisfies either type.

@tatumizer
Copy link

The compiler has to verify that the call makes sense statically. It has to reject (statically!) the call that has no chance of being both A's foo and B's foo. For this, it has to be able to derive the signature of the "common denominator" - that is, the method that satisfies both foos.

Imagine you are writing the code of foo in IDE. You type arg.toPrettyString( CTRL-SPACE. What suggestion do you expect to see?

@mmcdon20
Copy link

mmcdon20 commented Nov 4, 2024

The compiler has to verify that the call makes sense statically. It has to reject (statically!) the call that has no chance of being both A's foo and B's foo. For this, it has to be able to derive the signature of the "common denominator" - that is, the method that satisfies both foos.

Okay I think this is where our understanding differs. The type A & B doesn't actually have to have a single method that satisfies both. You would still be able to declare a type A & B, but you can not create instances of such types, since the compiler won't allow you to extend/implement both types due to the collision, hence they are a bit like Never types.

Imagine you are writing the code of foo in IDE. You type arg.toPrettyString( CTRL-SPACE. What suggestion do you expect to see?

I'd expect to see both method signatures suggested.

@tatumizer
Copy link

tatumizer commented Nov 4, 2024

No. It can't suggest arg.toPrettyString(int). It has to suggest an arg.toPrettyString() only, because this is the only one that can in principle satisfy both. This is true for the original definition

class A {
  String toPrettyString() => 'A';
}
class B {
  String toPrettyString(int x) => 'B$x';
}

With another definition (with (Foo, Bar) in one place and (int) in another)... I don't know... probably again it's arg.toPrettyString().
But sometimes common denominator doesn't exist, and then the compiler has to flag the attempt of an invocation, or (better) the expression A & B.

@lrhn: please help! 😄

@lrhn
Copy link
Member

lrhn commented Nov 4, 2024

What @mmcdon20 says.

The analyzer can just show both types. That's the most correct answer.
If it can five a single function type which represents the same thing, then good for it. Can't assume that's always the case, especially if there are no union types.

If you have

class A {
  String foo(A a) => '...';
}
class B {
  String foo(B x, B y) => '...';
}

and something of type A&B, it's both an A and a B.
What that means is one of the feature design choices.

(Ignore whenever the argument types are final types, that's something one can special-case, but it shouldn't be necessary. Ignore what the existing method implementations are, a type which is both an A and a B will have it's own implementation and signature.
The A&B is just all you know about that signature.)

At a minimum, it should allow both x.foo(A()) x.foo(B(), B()).
It is an A and a B so it can be called as either.

Maybe it can be called as x.foo(B()), because B allows a first argument of B and A allows being called with only one argument. (This assumes that the actual function has a single function type which is a subtype of both function types, and so it concludes that that function type must be a subtype of String Function(A|B, [B)]. Even if it never represents that union type explicitly. But that's a conclusion based on the current limitations of function types.)

The member signature can be represented as the intersection type
String Function(A) & String Function(B,B), and the feature design is to say what the capabilities of that function is.
Unless the functions are incompatible, it's possible to define a way to call it.
Or at least call it as either type would itself allow.

If the function types are incompatible, like void Function<T>() and void Function(), the compiler can say that the function cannot be called, because no argument list (including type arguments) is compatible with both.
Or it can allow it, as long as it satisfied one of the function types, and it's your problem to ever produce an instance of such a type. (You can't.)

@mmcdon20
Copy link

mmcdon20 commented Nov 4, 2024

The member signature can be represented as the intersection type
String Function(A) & String Function(B,B)

That's an interesting idea. If this were the case, could that also potentially allow method overloading in dart?

For example

class A {
  int foo(int a, int b) => a + b;
  String foo(String msg) => 'hello $foo';
}

void main() {
  final f = A().foo; // int Function(int, int) & String Function(String);
}

@tatumizer
Copy link

@lrhn wrote:

The analyzer can just show both types. That's the most correct answer.

I know, there's a strong argument for this: if A & B is feasible, then we can use either signature by definition.
But then, I can't understand an earlier example:

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!
}

What do you mean by "don't touch it"? You just said that IDE can suggest both signatures. That is, I can write

void foo<T extends A&B>(T value) { 
  value.x; // using suggestion #1
  value.x(); // using suggestion #2
}

I've just touched them! The funny thing is that it absolutely doesn't matter whether I touched them or not - because the function foo will never be called. Any call site that tries to call this method would be flagged.
Indeed, to be able to call it, I would need a class C:

class C implements A, B {
   // impossible to define it satisfying both A and B
}

So no matter what parameter I try to pass while calling the method (like foo(myValue);), the compiler will say that myValue is incompatible with the expected type A&B. What gives?

@lrhn
Copy link
Member

lrhn commented Nov 4, 2024

My point there is that with two different methods signatures, the compiler knows that any object implementing both will have a method with a signature that is a subtype of both. It doesn't know which signature, but it can deduce some ways that it can be validly called.
It's a method member whose function type is an intersection type.

With one getter and one method, the compiler has no idea where to start.
A getter and a method has no common way to access them. One is invoked as a getter without any argument list, the other is invoked as a function with an argument list. Saying "you can do either, or both" doesn't help here. No entity can be both.

Intersection types are for types.
This would require intersection member signatures, which includes the kind (getter vs method).
That's not included in the future discussed so far.

So if you do write value.x, the compiler can't assign any type to that, because it would be asking about the type of something that it cannot represent.

@tatumizer
Copy link

An argument can be made that if A and B contain a method x with different signatures, then A & B is empty.
The argument is based upon one assumption - please confirm or deny.
Suppose A and B both implement a method x with different signatures: one is x(String s), another is x(int x).

Consider a method

foo(A & B value) {
   var arg = something;
   value.x(arg); // suppose the compiler allows the call
   (value as A).x(arg); // then these should be allowed, too
   (value as B).x(arg); 
}

Why the last two calls should be allowed? Because all the compiler knows about the value is that implements both A and B.
Then the casts value as A, value as B are actually redundant.
Clearly, whatever type of arg is, it can't satisfy both.
How can you counter that?

@mmcdon20
Copy link

mmcdon20 commented Nov 5, 2024

An argument can be made that if A and B contain a method x with different signatures, then A & B is empty. The argument is based upon one assumption - please confirm or deny. Suppose A and B both implement a method x with different signatures: one is x(String s), another is x(int x).

Consider a method

foo(A & B value) {
   var arg = something;
   value.x(arg); // suppose the compiler allows the call
   (value as A).x(arg); // then these should be allowed, too
   (value as B).x(arg); 
}

Why the last two calls should be allowed? Because all the compiler knows about the value is that implements both A and B. Then the casts value as A, value as B are actually redundant. Clearly, whatever type of arg is, it can't satisfy both. How can you counter that?

One of the last two calls would result in a compile error. For comparison see the equivalent program in typescript.

class A {
    x(s: String) {}
}

class B {
    x(x: number) {}
}

function foo(value: A & B) {
    var arg = 3;
    value.x(arg);
    (value as A).x(arg); // Argument of type 'number' is not assignable to parameter of type 'String'.
    (value as B).x(arg);
}

The above program produces a compile error at the commented line.

@tatumizer
Copy link

tatumizer commented Nov 5, 2024

Could you check the suggestion shown by IDE when you type value.x( CRTL-SPACE. Isn't it a kind of "Object arg"?
What happens if you try to call value.x(Object()) ?

@mmcdon20
Copy link

mmcdon20 commented Nov 5, 2024

Could you check the suggestion shown by IDE when you type value.x( CRTL-SPACE. Isn't it a kind of "Object arg"?

@tatumizer you can try yourself on https://www.typescriptlang.org/play

The editor shows both with a 1/2 x(s: String): void and 2/2 x(x: number): void with arrows to switch between 1/2 and 2/2.

What happens if you try to call value.x(Object()) ?

The compiler appears fine with that.

@tatumizer
Copy link

I see. X is (A & B) iff A is X AND B is X.
Example: Object is (number & String) because number is Object AND String is Object.
This is not quite intuitive. One might (erroneously) think X is (A & B) iff X is A and X is B. But this is not the case with the above definition.
There must be some logic behind the definition (and notation) but I don't understand it.

@mmcdon20
Copy link

mmcdon20 commented Nov 5, 2024

I see. X is (A & B) iff A is X AND B is X. Example: Object is (number & String) because number is Object AND String is Object. This is not quite intuitive. One might (erroneously) think X is (A & B) iff X is A and X is B. But this is not the case with the above definition. There must be some logic behind the definition (and notation) but I don't understand it.

No it should be iff X is A AND X is B. That said I'm not sure why value.x(Object()) is allowed by typescript, but it could just be that typescript is not entirely sound (which it 100% isn't). It would make sense to me if Object() was a bottom type in typescript but as far as I know it is not.

However If you try the same thing in Scala you do get an error.

Scala example below:

trait A {
  def x(s: String): Unit
}

trait B {
  def x(s: Int): Unit
}

def foo(value: A & B) = {
  value.x(Object()) // None of the overloaded alternatives of method x in trait A with types
                    // (s: String): Unit
                    // (s: Int): Unit
                    // match arguments (Object)
}

Should also note that since Scala supports overloading there is no conflict between the two variations of x.

trait A {
  def x(s: String): Unit
}

trait B {
  def x(x: Int): Unit
}

class C extends A with B {
  def x(s: String): Unit = {}
  def x(x: Int): Unit = {}
}

def foo(value: A & B) = {
  value.x("A") // legal
  value.x(3) // legal
}

foo(C()) // legal

EDIT

Actually you do get an error when passing in new Object() in typescript as opposed to Object(). I'm not quite sure what is going on with the latter but you can't pass an instance of Object without getting an error.

class A {
    x(s: String) {}
}

class B {
    x(x: number) {}
}

function foo(value: A & B) {
    value.x(new Object());
    //   No overload matches this call.
    // Overload 1 of 2, '(s: String): void', gave the following error.
    //   Argument of type 'Object' is not assignable to parameter of type 'String'.
    //     The 'Object' type is assignable to very few other types. Did you mean to use the 'any' type instead?
    //       Type 'Object' is missing the following properties from type 'String': charAt, charCodeAt, concat, indexOf, and 37 more.
    // Overload 2 of 2, '(x: number): void', gave the following error.
    //   Argument of type 'Object' is not assignable to parameter of type 'number'.(2769)
}

@tatumizer
Copy link

The bottom line: the issue is moot 😄
Let's wait for @lrhn to elucidate.

@lrhn
Copy link
Member

lrhn commented Nov 6, 2024

@mmcdon20 is right.

class A {
    void x(String _) {}
}

class B {
    void x(num _) {}
}

void foo(A & B value) {
    value.x(3); // ok
    value.x("a"); // ok
    (value as A).x(3); // Argument of type 'intr' is not assignable to parameter of type 'String'.
    (value as B).x("a"); // also type error.
}

That's correct. The casts are up-casts.
An up-cast can change a parameter to be more restrictive.

Say we had

class C implements A, B {
    void x(OIbject _) {}
}

which was the actual runtime type of value. Then the errors make sense.
And so would they if the static type of value was Cinstead of A&B, unsurprisingly since C <: A&B.

(I'm JavaScript, I think Object() without new is a coercion of the argument to be a non-primitive value. Without an argument, that's a coercion of undefined. I'm guessing its value is null. TypeScript might know that, and make the static type Null or the TS equivalent.)

@tatumizer
Copy link

I understand this. But I find both the concept and the notation confusing.
Consider

class A {
  int foo(String s) => 0;
}
class B {
  int foo(int i) => 0;
}
class C implements A,B {
  int foo(Object o) => 0;  // yes, this is correct override
}

Now let's consider a function

func(A a)=>a.foo(Object());  // ERROR! The argument type 'Object' can't be assigned to the parameter type 'String'. 

So far so good. Now let's change A to A & B:

func(A & B a)=>a.foo(Object());  // no error 

Again, I understand the formalizm that leads to such behavior. But I find it bizarre. On the surface, it looks like we are tightening the type by imposing an additional requirement. But in reality, we remove all type restrictions.
It's like ordering a coffee with milk and sugar and instead getting a coffee with an Object in it. Sure, some theory can be developed to explain why this is exactly what I've ordered, but...
Based on what I've seen. I don't find the concept attractive. :-)

@TekExplorer
Copy link

TekExplorer commented Nov 6, 2024

That's because you don't expand to Object you expand to int|String which means you can pass either an int or a String.

It just so happens that the actual class would need to accept Object, but we don't actually know that in the function.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
request Requests to resolve a particular developer problem
Projects
None yet
Development

No branches or pull requests