dart-lang / dart-lang/language
Generic-constrained/subtype guarded instance members.
- Dominant language
- TeX
- Stars
- 2.9k
- Forks
- 239
- Avg merge
- 2d 18h
- Merged PRs (30d)
- 14
Description
Today there are things you can do with extensions on a class, but cannot write directly as class methods, so users write a class, and define an extension on the class in the same library.
A very common example is a method which only works on some subtypes/instantiations of a generic class (`extension on List ...`).
Then it becomes a problem that extensions _are_ defined separately from the class, need to be imported to work, and can conflict with other extensions (where proper instance members take precedence over extensions). And can't be invoked dynamically, because they aren't really there.
A proposed solution is "sticky extension methods", which is an extension which must be declared in the same library as the (class) type it's declared on (so it must be declared on a specific new type declaration), and which does not need to be imported when called on that type (or any subtype).
Those would presumably *only* be auto-available at any point where a receiver has a static type which is a subtype of the declared-on type, but can still be hidden by other, more specific, extensions (which is good, that how you can specialize them for subclasses) or conflict with equally specific ones (that's bad).
They'd still not be treated as inherent properties of the type itself, and compete with other extensions.
So I propose that we add the ability to have subtype/instantiation-type guarded instance methods, which are virtual and safe.
There are really *two* uses of this
* A single method only allowed on some subtypes, like `MyCollection.sort()` without a comparison function that is only allowed when `C` implements `Comparable`.
* Multiple methods specialized to different, unrelated, subtypes, `Pointer.value` has type `int`, `Pointer.value` has type `double`.
I'm only going to try to solve the first, easier, one, which doesn't require overloading.
Multi-methods is a harder issue, but still one that may be worthy of "sticky extensions" or better.
## Subtype-only members
Idea: Allow defining methods on a generic class with *requirements* on the type parameters:
```dart
abstract class Vector {
int get length;
T operator[](int);
void Vector>.sort() {
// No access to outer `T`, only to inner `X` type parameter, which has bound `Comparable`.
// You'd usually use the same name, but this is to emphasize that it's a *different* type variable,
// introduced by this declaration.
sort(Comparable.compare);
}
void sortBy(int Function(T, T) compare);
}
```
The constraint on the receiver type, expressed as the method being on `Vector>`,
means that it is a compile-time error to access (invoke or tear-off) the method on an instance with static type `Vector`
if `Z` does not implement `Comparable`. The compiler checks that. It is as if an object of type `Vector` doesn't have a `sort` method at all.
(Ignore the usual problems with `Comparable`, where `int` doesn't implement `Comparable`. May be solved with variance, if we can make it `class Comparable`.)
Since `T` is covariant, up-casting a `Vector` to `Vector` removes typed access to its `sort` member, which is safe. Downcasting will check that the instance has the desired type, before allowing an object to be accessed a that type supporting `sort`. All in all, typed access is safe and sound. It's not possible to call `vector.sort` in such a way that the type bound to `T` in `sort` does not implement `Comparable`.
Casting to dynamic and accessing `sort` is the issue then.
We will add a check to dynamic member access on subtype guarded members, so that it does effectively `(object as Vector).sort`. This matches the general design principle for `dynamic` access that is should work at runtime (if it works) just like a typed access would work if the receiver had the actual runtime type of the object as static type. If the static type did not allow `sort`, neither will dynamic access.
It's (yet) more overhead for `dynamic` access, but hopefully it can be abstracted into whatever dynamic access stubs the backend code already uses to check what it's doing.
(A failure should probably hit `noSuchMethod` in that case, which means we could implement dynamic access to `sort` as
`=> this is Vector> ? this.sort() : this.noSuchMethod(Invocation.method(#sort));`. Promote the `this`!)
Also, constructor can be limited in the same way:
```dart
abstract class OrderedArray implements Array, Comparable> {
factory OrderedArray(int size, T fill, int Function(T, T) compare) = _ComparatorOrderedArray;
factory OrderedArray>.comparable(int size, T fill) =>
_ComparatorOrderedArray(size, fill, (T a, T b) => a.compareTo(b)); // Valid, T extends Comparable
void sort();
}
```
(That alone is a useful feature, BTW!)
### Overriding
Subtype-guarded methods are virtual, and can be overridden.
How does that work.
The trivial example would be:
```dart
abstract class Array {
...
void Array>.sort();
}
abstract class GrowableArray implements Array {
void add(T);
// Do one of:
// - Nothing, inherit method.
// - Redeclare with same signature:
void Array> sort();
// - Redeclare as you would declare it in this class:
void GrowableArray> sort();
}
```
These should all be equivalent, because the type parameter of `List` is passed directly to `Iterable`.
What if it isn't that direct. Then it gets complicated fast.
Can you widen the bound?
```dart
class Foo {
int foo get something => ...
}
class Bar extends Foo {
int foo get something => ...
}
```
This should be *sound*. Any instance of `Bar` upcast to `Foo` will allow *fewer* invocations of `something`, but not any invocations which wouldn't be allowed on `Bar` itself.
However, we don't allow widening the bounds of generic methods in subclasses. It's not because it wouldn't be sound, so presumably it's because it's undecidable or prohibitively expensive to check. We can allow you to "widen the bounds", but if we can't efficiently check whether one set of bounds is wider than another, it's not a checkable property.
Checking that the bounds are *the same* is (apparently) easier.
I fear widening the constraints here has the same issues, but I'm not enough of a type constraint solving specialist to know. If so, we should not allow widening, and should require the *same* constraints.
What if the subclass doesn't have the same type parameters as the superclass?
```dart
abstract class C {
T1 get value;
void C.foo();
}
class D extends C {
T get value; // Valid override since `T` is a subtype of `T`.
// Do one of
// - nothing, inherit the same signature. Should work.
// - redeclare with same signature, should work:
void C.foo() { ... } // Checks implemented `C` interface for permission.
// - redeclare using current class:
void D.foo();
}
```
Using the `C` constraint "works", but doesn't provide a more specific type for `T`.
```dart
void C.foo() {
// What is type of `this`, `D` or C`?
// We know `this` implements C and C, so we can *possibly* know that
// T1 is T & num, and `T2` is `T`.
// We do *not* automatically know that `T` <: num.
num x = value; // Valid? Not as `D.value`, but as `C.value` it is.
}
```
Using the `D` constraint is more complicated.
It means that we constrain the invocation to be on `C` instances, which is a stronger bound than the superclass. It's one that the subclass instances cannot fail to satisfy, because the type variable of `D` is used non-lineraly, but we still have to be able to recongize that.
Just doing `C.foo()` in another subclass of `C` could be unsound, if upcasting to `C` would remove the constraint on `T2` from the singature.
I fear that allowing you to specify the constraint as the last `D` above *may* fall into the same complexity hole as widening constraints, it requires us to solve complex type equations with both co- and contra-variant occurrences of type parameters for whether they are strictly wider or narrower than each other, and whether the narrowing can be allowed because satisfying it is ensured by the definition of the class.
If that's doable, great, we should allow it. Possibly require you to always use the subclass name for overrides.
If not, we should possibly require the *same* constraint on every override, using the superclass name everywhere. And then it's still unclear how you link the `D` type variable and `C` type variables to each other, in a way which allows you to know that `this.value` has type `num` in `C.foo()` on a `D`.
Contributor guide
Research direction
Start with the issue's “Subtype-only members” and “Overriding” sections, then review the existing Dart rules for generic constraints and overrides. Done means producing a settled, implementable specification that resolves the inheritance, bound, constructor, and dynamic-access questions raised here.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- dart
- Domain
- compilers
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Needs clarification
- Newbie friendliness
- 20/100