Skip to content

Multiple upper bounds #2709

Open
Open
@anyoptional

Description

@anyoptional

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?

Activity

added
requestRequests to resolve a particular developer problem
on Dec 10, 2022
lrhn

lrhn commented on Dec 11, 2022

@lrhn
Member

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

clragon commented on Dec 11, 2022

@clragon

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

lrhn commented on Dec 11, 2022

@lrhn
Member

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

dnys1 commented on Dec 15, 2022

@dnys1

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

lrhn commented on Dec 15, 2022

@lrhn
Member

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

Wdestroier commented on Dec 16, 2022

@Wdestroier

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

lrhn commented on Dec 17, 2022

@lrhn
Member

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

reopened this on Aug 28, 2023
MarvinHannott

MarvinHannott commented on Sep 11, 2023

@MarvinHannott

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

clragon commented on Sep 11, 2023

@clragon

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

MarvinHannott

MarvinHannott commented on Sep 11, 2023

@MarvinHannott

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.

37 remaining items

ghost
mmcdon20

mmcdon20 commented on Nov 5, 2024

@mmcdon20

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.

ghost
mmcdon20

mmcdon20 commented on Nov 5, 2024

@mmcdon20

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.

ghost
mmcdon20

mmcdon20 commented on Nov 5, 2024

@mmcdon20

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)
}
ghost
lrhn

lrhn commented on Nov 6, 2024

@lrhn
Member

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

ghost
TekExplorer

TekExplorer commented on Nov 6, 2024

@TekExplorer

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.

jodinathan

jodinathan commented on Jan 16, 2025

@jodinathan

My simple mind thought this would be like a syntax sugar, ie:

mixin Foo {
  String exec() => 'foo';
}

mixin Bar {
  int exec() => 123;
}

class Daz with Foo, Bar {} // 'Bar.exec' ('int Function()') isn't a valid override of 'Foo.exec' ('String Function()').

then

class Foo {
  String exec() => 'foo';
}

class Bar {
  int exec() => 123;
}

class Daz<T extends Foo & Bar> {} // 'Bar.exec' ('int Function()') isn't a valid override of 'Foo.exec' ('String Function()').
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    requestRequests to resolve a particular developer problem

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

      Development

      No branches or pull requests

        Participants

        @munificent@dcharkes@lrhn@mmcdon20@jodinathan

        Issue actions

          Multiple upper bounds · Issue #2709 · dart-lang/language