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

Support with and implements keyword in generic constraint, allow multiple constraints for one generic type. #1152

Closed
nathanfranke opened this issue Aug 16, 2020 · 13 comments
Labels
request Requests to resolve a particular developer problem

Comments

@nathanfranke
Copy link

nathanfranke commented Aug 16, 2020

That's a long title, so I will try to demonstrate this problem.

  1. extends is only keyword allowed in generic constraints.

While classes, mixins, and interfaces are all pretty similar ideas, the fact that generic constraints rely on only extends is inconsistent with class signatures and also misleading.

For example:

class Abc<T extends Comparable<T>> { ... }

We say extends rather than implements. I would prefer this:

class Abc<T implements Comparable<T>> { ... }
  1. Maximum of one constraint per generic.

While classes can only have one inherited class, they can have that and also multiple mixins and or interfaces.

I would like to see the generic constraints follow the same syntax as the class signature. For example:

class Abc<T
		extends SomeClass
		with SomeMixin
		implements Comparable<T>> { ... }

Edit: Multiple mixins and interfaces are delimited with a comma, and class Abc<T implements InterfaceA, InterfaceB> is ambiguous.

I propose one of these:

class Abc<T implements InterfaceA implements InterfaceB> { ... }
class Abc<T implements InterfaceA and InterfaceB> { ... }
class Abc<T implements (InterfaceA, InterfaceB)> { ... }
class Abc<T implements [InterfaceA, InterfaceB]> { ... }
@nathanfranke nathanfranke added the request Requests to resolve a particular developer problem label Aug 16, 2020
@lrhn
Copy link
Member

lrhn commented Aug 16, 2020

Allowing multiple bounds on a type variable can easily introduce intersection types.
If you have:

foo<T extends Foo & Bar>(T v) {
  var z = v.baz; // What is type of `z`?
  return z;
}

what would the type of z/v.baz be if both Foo and Bar declares a baz getter?
Say:

abstract class Foo {
   Foo get baz;
}
abstract class Bar {
  Bar get baz;
}
class Baz implements Foo, Bar {
  Qux get baz => ...;
}
class Qux implements Foo, Bar {
  Baz get baz => ...;
}

The only correct choice is the intersection type Foo&Bar, because that is all we actually know.
Anything less precise than that means that having both Foo and Bar as bounds on T is less useful.

We could say that you can't invoke methods on T, or use the result of such an invocation, unless the methods have the same signature on both Foo and Bar. (Or unless the arguments are valid for both, but you still can't use the result).

You can do cheap up-casts to both Foo and Bar, which would allow you to get a result, just not one which statically satisfy both return types.

Using the extends/with/implements syntax for type variable bounds is probably not going to do what it suggests. We do not check whether a type actually extends the bound, the current syntax uses extends with a completely different meaning than a class declaration. I agree that implements would be better, but it really should be <T is Foo>, because it is just a type check, not any structural thing.
Doing <T extends Foo with Bar implements Baz> would not work to only require types extending Foo and mixing in Bar, because any class can implement Foo and Bar and should be usable everywhere a Foo or Bar is required (subtype substitutability).

@nathanfranke
Copy link
Author

nathanfranke commented Aug 16, 2020

I am not suggesting that T should be able to extend either. It must extend/implement every type constraint. Your example with extends Foo & Bar would not be possible because a type cannot extend multiple classes.


mixin A {}
mixin B {}
mixin C {}
class Abc<T with [A, B, C]> {
}

class Test1 with A {}
Abc<Test1>(); // Error
class Test2 with A, B {}
Abc<Test2>(); // Error
class Test3 with A, B, C {}
Abc<Test3>(); // Valid

C# Example:

class A {

}
interface B {

}
class Abc<T> where T : A, B {

}

@eernstg
Copy link
Member

eernstg commented Aug 17, 2020

@lrhn mentioned the implied intersection types, but the proposal seems to imply a number of other requirements: If we specify X extends SomeClass implements AnotherClass then it must actually do that.

I think we'd need a strong use case and in general some more motivation for why we'd need these new and more detailed constraints on the possible values of type variables.

One difficulty is that the ability to constrain the value of a type variable to be a type that extends some type, implements some other type, and mixes in with yet another type would introduce a lot of new dependencies in code.

Those dependencies would force properties to be maintained that may otherwise be considered implementation details: If I know that x.foo() is allowed when x has type X, who cares whether this is because the type of x is a class that inherits an implementation of foo from a specific other class (so it's extends rather than implements)? Also, even if we have the guarantee that the value of X is a class (let's call it D) that extends C (so it's not implements C), the value of x could still be an instance of a class D2 that implements D. So we wouldn't ever have the guarantees concerning superclasses and mixins anyway, because you could in every case be working with an instance that just has an implements relation to the bound.

Another issue is having multiple edges on the path that establishes a typing relation. If we require that X implements C<num> then we may wish to allow that X has the value D<int> where class D<Y> implements D0<Y> {} and class D0<Y> implements C<Y> {}. But what if that second relationship is extends rather than implements, would it still satisfy X implements C<int>? And how about X implements C<num>, do you allow for covariance in line with subtyping? Again, an actual instance of type X will just have to have a type which is a subtype of the value of X, and that subtype relationship could certainly use covariance.

In summary, I do recognize that the ability to use bounds like T extends SomeClass with SomeMixin implements Comparable<T> would allow type variables to have much more detailed constraints, but I'm not convinced that the added expressive power will be helpful, and I doubt that the new constraints can be enforced in a meaningful way.

@nathanfranke
Copy link
Author

we'd need a strong use case

The use case is for more complex type constraints which can definitely happen.

Comparable<T> is by far the best example, because if a class contains a generic field that needs to be compared in compareTo, T must implement Comparable<T>. However, since only one constraint is allowed, T cannot extend anything else.

Here is my original setup with this problem

// Serializable class. Other classes can extend and implement `serialize`
// to denote that they can be serialized to bytes.
abstract class Serializable {
	Uint8List serialize();
}

// Contains a generic type T and wraps it's serialization and comparison methods.
// Since this class is serializable, T must also extend serializable.
// Since this class is comparable, T must also implement comparable
class SerializableContainer<T
				extends Serializable
				implements Comparable<T>>
		extends Serializable
		implements Comparable<SerializableContainer<T>> {
	
	// Value that this container stores
	T value;
	SerializableContainer(this.value);
	
	// Wrap around T's serialize method
	Uint8List serialize() => value.serialize();
	// Wrap around T's compareTo method
	int compareTo(SVal other) => value.compareTo(other.value);
}

// Simple class that wraps an int value that can be serialized and compared
class SVal extends Serializable implements Comparable<SVal> {
	// Integer that the wrapper stores
	int value;
	SVal(this.value);
	
	// A function that serializes the integer
	Uint8List serialize() { ... }
	// Compare this to another SVal using the integer comparison method.
	int compareTo(SVal other) => value.compareTo(other.value);
}

The only workaround I know is to make Serializable implement Comparable<T>. However, since Comparable<T> also has its own type, that type needs to be wrapped manually until a top level function like SVal. The code is much uglier in comparison, redundant because of above, and forces every Serializable class to also be Comparable<T>, which may be undesired.

// This time, Serializable implements Comparable.
abstract class Serializable<T> implements Comparable<T> {
	void serialize();
}

// Since Serializable implements Comparable, we do not need to add that constraint here.
class SerializableContainer<T extends Serializable<T>> extends Serializable<SerializableContainer<T>> {
	T value;
	SerializableContainer(this.value);
	void serialize() {}
	int compareTo(SerializableContainer<T> other) {
		// T extends Serializable which implements Comparable so this call is valid.
		return value.compareTo(other.value);
	}
}

// Since we need to pass SVal to Comparable, we need to first pass it through Serializable.
class SVal extends Serializable<SVal> {
	int value;
	SVal(this.value);
	void serialize() {}
	int compareTo(SVal other) {
		return value.compareTo(other.value);
	}
}

@eernstg I really cannot understand most of what you are talking about. Can you give some code examples and simplify what you're saying? To be honest, I just see a bunch of different letters and extends.

I don't think this proposal is that complicated and many other languages (Such as C# that I have mentioned above) have implemented this already.

@MarvinHannott
Copy link

MarvinHannott commented Jan 30, 2022

C# solves the problem of intersection types by allowing interfaces to get implemented explicitly. When type bounds overlap, the object needs to be casted.

Rust does it a similar way. Traits can be implemented independent of each other. Then, when trait bounds overlap, the caller needs to explicitly call the trait method on the object (for the user, it does the same thing as C#). (By the way, being able to implement interfaces by means of virtual extension methods would also be a really, really neat feature).

Dart already does something very similar with extension methods: when multiple extensions implement the same method, users have to call one extension explicitly.

So, while it might not be a simple feature to implement, it is very, very useful. For example, I encountered the following problem: I needed the type bound TypedData + List<int>, but this is impossible to express in Dart, and this one also isn't solvable by a wrapper type. If one tries to, for example, create an abstract class that implements both TypedData and List<int>; if we use this class as our type bound, and then create a wrapper class for typed lists (Uint8List, Int16List, etc.) that satisfies this type bound, then one quickly encounters the problem that one could either wrap a TypedData or a List<int>, but not both, even though every typed list satisfies both interfaces.

@eernstg
Copy link
Member

eernstg commented Feb 4, 2022

Long ago, @nathanfranke wrote:

Can you give some code examples

Ah, sorry, I overlooked that comment! Just noticed it now that there's a new comment on this issue. But let me give a couple of comments on the initial text of this issue:

extends is only keyword allowed in generic constraints.

This is the part that I was responding to. An example was given:

// A type parameter with a bound, as it is written today.
class Abc1<T extends Comparable<T>> { ... }

// Proposed new feature.
class Abc2<T implements Comparable<T>> { ... }

I interpreted this proposal to mean that we would be able to specify constraints on type arguments in new ways, with a new meaning (I assumed that it wouldn't merely be two alternative syntaxes for exactly the same thing).

So, presumably we'd have the following:

class A1 extends Comparable<A1> {...} // Assume that this class has no errors.
class A2 implements Comparable<A2> {...} // Ditto, no errors.

void foo(
  Abc1<A1> x1, // OK.
  Abc2<A2> x2, // OK.
  Abc1<A2> x3, // Compile-time error! Has `implements`, but `extends` is required.
  Abc2<A1> x4, // Compile-time error! Has `extends`, but `implements` is required.
) {}

The rule would now be that an extends clause on a type parameter requires an extends relationship for the actual type argument, and similarly for implements. Similarly, a with M clause on a type parameter would require that the actual type argument has M as a mixin. We would have to consider those rules together with transitive relationships (for instance, would Abc1<B1> be allowed if class B1 implements A1 {...}? There is an extends link in the superinterface graph from B1 to Comparable<A1> but it isn't a path that exclusively consists of extends links; and so on and so on). We could also allow Abc2<A1> based on the judgment that extends is a more "powerful" relation than implements, so we may wish to require extends, but when we specify implements we're happy about any of extends and implements, or even extends and implements and with. And so on and so on.

This is the kind of ruleset that I thought would require a really convincing use case.

I honestly don't see the problem that it solves, and I think it would cause a large amount of complexity. Also, I'd consider this kind of rule to be a pervasive violation of encapsulation: Abc1 isn't supposed to make such a distinction between A1 and A2, it should be happy with each of them because they're both such a T that satisfies that T is a subtype of Comparable<T>. In other words, I think the current, simple rules are better than these more elaborate ones.

The issue mentions a different topic (that I consider unrelated, or at least very different):

Maximum of one constraint per generic.

This is basically a request for intersection types, at least when they occur as upper bounds of type parameters.

We could certainly introduce intersection types into the Dart type system, but this is a rather substantial addition, and there would be many details to sort out.

In any case, that would be a separate proposal, with details. It could be made part of a union types proposal (cf. #83), because union types give rise to intersection types, and vice versa.

So it would probably be a good idea to discuss intersection types in #83, or at least to add a comment there such that the #83 crowd is aware of the proposal about intersection types.

@jakobleck
Copy link

My 2 cents on the matter @eernstg :

// A type parameter with a bound, as it is written today. (Not nice, deprecate?)
class Abc1<T extends Comparable<T>> { ... }

// Proposed new feature.
class Abc2<T implements Comparable<T>> { ... }

class A1 extends Comparable<A1> {...} // Assume that this class has no errors.
class A2 implements Comparable<A2> {...} // Ditto, no errors.

void foo(
  Abc1<A1> x1, // OK.
  Abc2<A2> x2, // OK.
  Abc1<A2> x3, // OK. Currently accepted by the compiler.
  Abc2<A1> x4, // OK. When a class extends another class, it automatically implements its interface.
) {}

As mentioned by @lrhn right in the first comment, <T is Foo> is actually what is happening and having <T implements Foo> more does what it says on the tin than <T extends Foo>. The fact that you can do the below currently really underlines that 'extends' seems to be the wrong keyword here because it is more about which interface the type parameter implements:

void main() {
  GenericConsumer(Extender()).doStuff();
  GenericConsumer(Implementer()).doStuff();
}


abstract class BaseInterface {
  void printStuff();
}


class Extender extends BaseInterface {
  @override
  void printStuff() {
    print("Hi there! Says the extended class.");
  }
}

class Implementer implements BaseInterface {
  @override
  void printStuff() {
    print("Hi there! Says the implementing class.");
  }
}

class GenericConsumer<T extends BaseInterface> {
  T instance;
  
  GenericConsumer(this.instance);
  
  void doStuff() => instance.printStuff();
}

@lrhn
Copy link
Member

lrhn commented May 17, 2022

To be perfectly clear: Dart uses extends for type parameter upper-bounds because both Java and C# already did, and Dart was originally deliberately designed to be "non-surprising" to existing Java and C# (and JavaScript) developers.

There was no discussion about whether another word might be more or less precise, it had to be much better to compete with something people already knew. It existed, it worked, people already knew it.

We have no current plans to change the word. That's simply not worth the effort. It still works. There are no glaring problems with it. Changing it would be an incredibly large and wasteful enterprise with very minuscule benefit, if any.

If we ever completely revamp type parameter syntax, like adding multiple bounds, or lower bounds, or something we haven't even thought of yet, we might consider whether we should change extends, but ... even then it's probably still not worth it.

@jakobleck
Copy link

From a Java tutorial on upper bounded wildcards:
"To declare an upper-bounded wildcard, use the wildcard character ('?'), followed by the extends keyword, followed by its upper bound. Note that, in this context, extends is used in a general sense to mean either "extends" (as in classes) or "implements" (as in interfaces)."
Not intuitive, but then dart is indeed consistent with Java conventions.

@eernstg
Copy link
Member

eernstg commented May 18, 2022

My 2 cents on the matter @eernstg: ...

I agree completely with @lrhn's response here: extends in a type parameter declaration means "is a subtype of", no more, no less, and it's not worth the trouble to change it.

@clragon
Copy link

clragon commented Dec 11, 2022

I would like to discuss this topic a bit further.
I am not interested in having multiple keywords, like implements and with that actually mean the Type has to specifically implement the class or have it as a mixin. Simply constraining a generic type to be a subclass of another is enough for me.
But I would like to do this with multiple types.

The following:
T extends Type1 extends Type2

This, just like the previous extends, does not care about how T is using Type1 or Type2 spcifically, just that it is a subtype of them.
This would allow more concrete restrictions of types and would be non-breaking, and I hope also easy to implement.

Edit: I see there is another issue for that: #2709

@stan-at-work
Copy link

Any updated on this? This would make the language way more structured. In big projects

@lrhn
Copy link
Member

lrhn commented Oct 1, 2024

No update. No plans to do anything, so might as well close this issue.

For multiple bounds, see #2709.

@lrhn lrhn closed this as not planned Won't fix, can't repro, duplicate, stale Oct 1, 2024
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

7 participants