You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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<int> ...).
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<C>.sort() without a comparison function that is only allowed when C implements Comparable<C>.
Multiple methods specialized to different, unrelated, subtypes, Pointer<Uint8>.value has type int, Pointer<Float64>.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:
abstractclassVector<T> {
intget length;
Toperator[](int);
voidVector<XextendsComparable<X>>.sort() {
// No access to outer `T`, only to inner `X` type parameter, which has bound `Comparable<X>`.// 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<X>);
}
voidsortBy(intFunction(T, T) compare);
}
The constraint on the receiver type, expressed as the method being on Vector<X extends Comparable<X>>,
means that it is a compile-time error to access (invoke or tear-off) the method on an instance with static type Vector<Z>
if Z does not implement Comparable<Z>. The compiler checks that. It is as if an object of type Vector<Type> doesn't have a sort method at all.
(Ignore the usual problems with Comparable, where int doesn't implement Comparable<int>. May be solved with variance, if we can make it class Comparable<in T>.)
Since T is covariant, up-casting a Vector<String> to Vector<Object> 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<T>.
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<Comparable>).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<Comparable<T>> ? this.sort() : this.noSuchMethod(Invocation.method(#sort));. Promote the this!)
Also, constructor can be limited in the same way:
abstractclassOrderedArray<T> implementsArray<T>, Comparable<OrderedArray<T>> {
factoryOrderedArray(int size, T fill, intFunction(T, T) compare) =_ComparatorOrderedArray<T>;
factoryOrderedArray<TextendsComparable<T>>.comparable(int size, T fill) =>_ComparatorOrderedArray<T>(size, fill, (T a, T b) => a.compareTo(b)); // Valid, T extends Comparable<T>voidsort();
}
(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:
abstractclassArray<T> {
...
voidArray<TextendsComparable<T>>.sort();
}
abstractclassGrowableArray<T> implementsArray<T> {
voidadd(T);
// Do one of:// - Nothing, inherit method.// - Redeclare with same signature:voidArray<TextendsComparable<T>> sort();
// - Redeclare as you would declare it in this class:voidGrowableArray<TextendsComparable<T>> 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?
classFoo<T> {
int foo<Textendsint>get something => ...
}
classBar<T> extendsFoo<T> {
int foo<Textendsnum>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?
abstractclassC<T1, T2> {
T1get value;
voidC<T1extendsnum, T2>.foo();
}
classD<T> extendsC<T, T> {
Tget 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:voidC<T1extendsnum, T2>.foo() { ... } // Checks implemented `C` interface for permission.// - redeclare using current class:voidD<Textendsnum>.foo();
}
Using the C constraint "works", but doesn't provide a more specific type for T.
voidC<T1extendsnum, T2>.foo() {
// What is type of `this`, `D<T>` or C<T & num, T>`?// We know `this` implements C<T, T> and C<num, T2>, 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<T>.value`, but as `C<T&num, T>.value` it is.
}
Using the D<T extends num> constraint is more complicated.
It means that we constrain the invocation to be on C<num, num> 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<T1 extends num, T2 extends num>.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<T extends num> 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<T> type variable and C<T1 extends num, T2> type variables to each other, in a way which allows you to know that this.value has type num in C<T1 extends num, T2>.foo() on a D.
The text was updated successfully, but these errors were encountered:
lrhn
added
the
feature
Proposed language feature that solves one or more problems
label
Jun 30, 2023
Plug num into D<num> and look at the instantiated supertype C<List<num>, num>'s .foo.
The constraint T extends List<int> has a bound which is a subtpe of List<num>, so allowed.
All in all, seems like it could work. Syntax may need more work.
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<int> ...
).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
MyCollection<C>.sort()
without a comparison function that is only allowed whenC
implementsComparable<C>
.Pointer<Uint8>.value
has typeint
,Pointer<Float64>.value
has typedouble
.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:
The constraint on the receiver type, expressed as the method being on
Vector<X extends Comparable<X>>
,means that it is a compile-time error to access (invoke or tear-off) the method on an instance with static type
Vector<Z>
if
Z
does not implementComparable<Z>
. The compiler checks that. It is as if an object of typeVector<Type>
doesn't have asort
method at all.(Ignore the usual problems with
Comparable
, whereint
doesn't implementComparable<int>
. May be solved with variance, if we can make itclass Comparable<in T>
.)Since
T
is covariant, up-casting aVector<String>
toVector<Object>
removes typed access to itssort
member, which is safe. Downcasting will check that the instance has the desired type, before allowing an object to be accessed a that type supportingsort
. All in all, typed access is safe and sound. It's not possible to callvector.sort
in such a way that the type bound toT
insort
does not implementComparable<T>
.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<Comparable>).sort
. This matches the general design principle fordynamic
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 allowsort
, 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 tosort
as=> this is Vector<Comparable<T>> ? this.sort() : this.noSuchMethod(Invocation.method(#sort));
. Promote thethis
!)Also, constructor can be limited in the same way:
(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:
These should all be equivalent, because the type parameter of
List
is passed directly toIterable
.What if it isn't that direct. Then it gets complicated fast.
Can you widen the bound?
This should be sound. Any instance of
Bar
upcast toFoo
will allow fewer invocations ofsomething
, but not any invocations which wouldn't be allowed onBar
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?
Using the
C
constraint "works", but doesn't provide a more specific type forT
.Using the
D<T extends num>
constraint is more complicated.It means that we constrain the invocation to be on
C<num, num>
instances, which is a stronger bound than the superclass. It's one that the subclass instances cannot fail to satisfy, because the type variable ofD
is used non-lineraly, but we still have to be able to recongize that.Just doing
C<T1 extends num, T2 extends num>.foo()
in another subclass ofC
could be unsound, if upcasting toC
would remove the constraint onT2
from the singature.I fear that allowing you to specify the constraint as the last
D<T extends num>
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<T>
type variable andC<T1 extends num, T2>
type variables to each other, in a way which allows you to know thatthis.value
has typenum
inC<T1 extends num, T2>.foo()
on aD
.The text was updated successfully, but these errors were encountered: