Skip to content

Generic-constrained/subtype guarded instance members. #3181

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

Open
lrhn opened this issue Jun 30, 2023 · 2 comments
Open

Generic-constrained/subtype guarded instance members. #3181

lrhn opened this issue Jun 30, 2023 · 2 comments
Labels
feature Proposed language feature that solves one or more problems

Comments

@lrhn
Copy link
Member

lrhn commented Jun 30, 2023

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:

abstract class Vector<T> {
   int get length;
   T operator[](int);

   void Vector<X extends Comparable<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>);
   }

   void sortBy(int Function(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:

abstract class OrderedArray<T> implements Array<T>, Comparable<OrderedArray<T>> {
  factory OrderedArray(int size, T fill, int Function(T, T) compare) = _ComparatorOrderedArray<T>;
  factory OrderedArray<T extends Comparable<T>>.comparable(int size, T fill) => 
      _ComparatorOrderedArray<T>(size, fill, (T a, T b) => a.compareTo(b)); // Valid, T extends Comparable<T>
  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:

abstract class Array<T> {
  ...
  void Array<T extends Comparable<T>>.sort();
}
abstract class GrowableArray<T> implements Array<T> {
  void add(T);

  // Do one of:
  // - Nothing, inherit method.
  // - Redeclare with same signature:
  void Array<T extends Comparable<T>> sort();
  // - Redeclare as you would declare it in this class:
  void GrowableArray<T extends Comparable<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?

class Foo<T> {
  int foo<T extends int> get something => ...
}
class Bar<T> extends Foo<T> {
  int foo<T extends num> 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?

abstract class C<T1, T2> {
   T1 get value;
   void C<T1 extends num, T2>.foo();
}
class D<T> extends C<T, T> {
  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<T1 extends num, T2>.foo() { ... } // Checks implemented `C` interface for permission.
  // - redeclare using current class:
  void D<T extends num>.foo(); 
}

Using the C constraint "works", but doesn't provide a more specific type for T.

  void C<T1 extends num, 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.

@lrhn lrhn added the feature Proposed language feature that solves one or more problems label Jun 30, 2023
@lrhn
Copy link
Member Author

lrhn commented Jul 4, 2023

Erik suggested a syntax using a guard instead of a full type declaration with new type variables:

abstract class Vector<X> {
  // ...
  <if X extends num> X sum();
}

This avoids having to introduce new variables, with new constraints, and simply "promotes" the type variable in-place.

It also makes it easier to check whether a subtype override is valid:

abstract class C<X> {
  <if X extends List<int>> void foo();
}
abstract class D<X> extends C<X> {
  <if X extends Iterable<int>> void foo();
}

The override checking here would check:

  • Start with D.foo, so take the extra bound of X, Iterable<int>.
  • Plug that back into D as D<Iterable<int>> and look at where it occurs in supertype, here C<Iterable<int>>.
  • Go to C<Iterable<int>>.foo and check that the bound in the constraint T extends num is a subtype of the type bound to X.

For a more complicated example, like:

abstract class C<T1, T2> {
   <if T1 extends List<int>> void foo();
}
abstract class D<T> extends C<List<T>, T> {
  <if T extends num> void foo();
}

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.

@eernstg
Copy link
Member

eernstg commented Jul 4, 2023

Further details along these lines are available at #2313.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Proposed language feature that solves one or more problems
Projects
None yet
Development

No branches or pull requests

2 participants