dart-lang / language

Design of the Dart language
Other
2.65k stars 201 forks source link

Wrong type parameter variance error when passing generic function type to superclass constructor in Dart #2921

Open DrafaKiller opened 1 year ago

DrafaKiller commented 1 year ago

Description

When passing a generic function type to a superclass constructor in Dart, a wrong_type_parameter_variance_in_superinterface error is raised, indicating that the type parameter can't be used contravariantly or invariantly in the superclass.

Steps to reproduce

  1. Create a class with a type parameter that extends a generic function type:

    typedef Callback<T> = void Function(T value);
    
    abstract class CallbackObject<T extends Function> {
    final T callback;
    const CallbackObject(this.callback);
    }
    
    class MyClass<T> extends CallbackObject<Callback<T>> {
    const MyClass(super.callback);
    }
  2. Observe the wrong_type_parameter_variance_in_superinterface error at the T type parameter of the MyClass declaration.

Expected behavior

The code should compile without any errors.

Actual behavior

The wrong_type_parameter_variance_in_superinterface error is raised, indicating a variance mismatch between the CallbackObject class and its subclass MyClass.

Workaround

One possible workaround is to create another type parameter in the subclass MyClass that extends the Callback type, like so:

class MyClass<T, CallbackT extends Callback<T>> extends CallbackObject<CallbackT> {
  const MyClass(super.callback);
}

Environment

lrhn commented 1 year ago

The problem here is the MyClass class itself. It doesn't matter that Callback<T> is a subtype of Function for every T, it's not satisfying the superclass constraint which is the problem, but being internally consistent.

Dart type parameters are covariant, which means that MyClass<int> is a subtype of MyClass<num> by definition.

But void Function(T) is contravariant in T. That gives us a soundness issue:

void Function(int) f = (int x) {}
MyClass<int> mci = MyClass<int>(f);
MyClass<num> mcn = mci; // Valid upcast to generic supertype.
CallbackObject<void Function(num)> cn = mcn; // Valid upcast to declared superinterface.
void Function(num) g = cn.callback; // Correct static typing.
g(2.5); // Calling `f` with a double.

Dart allows unsound covariance in some situations, where it's possible to statically insert checks at runtime to ensure that things are sound. For example:

class FunctionBox<T> {
   void Function(T) value;
   FunctionBox(this.value);
}
FunctionBox<num> box = FunctionBox<int>((int x) {});
void Function(num) f = box.value; // Throws at runtime.

Here Dart inserts a type check on every box.value read, because it's possible to see that the returned value uses the type parameter contravariantly.

This is not one of those cases, because the contravariance would be in the type hierarchy itself. The void Function(num) g = cn.callback; read above sees no issue, the type parameter of CallbackObject occurs only covariantly in that expression.

So to prevent this unsoundness, you are simply not allowed to declare the MyClass class with a super-interface type argument which uses T contravariantly.

DrafaKiller commented 1 year ago

@lrhn Thank you, I understand your explanation.

I'm having trouble because this goes against my intuition.

The goal of the example is to allow MyClass to choose which callback it should have. My intuition is that T extends Function means that it will accept any Function. And from what we have already seen, it display the error:

abstract class CallbackObject<T extends Function> {
  final T callback;
  const CallbackObject(this.callback);
}

class MyClass<T> extends CallbackObject<void Function(T value)> {
  const MyClass(super.callback);
}

But when applying the same intention but using List, where T extends List means that it will accept any List. It doesn't display the error, even thought MyClass is still not boxed:

abstract class CallbackObject<T extends List> {
  final T callback;
  const CallbackObject(this.callback);
}

class MyClass<T> extends CallbackObject<List<T>> {
  const MyClass(super.callback);
}

I'm assuming it's because Function is contravariant and List is not.


Here's an example which I can't wrap my head around, which also goes against my intuition:

class ObjectA { }
class ObjectB extends ObjectA { }

class A<T extends ObjectA> { }
class B<T extends void Function(ObjectA)> { }

void test(ObjectA value) { }

void main() {
  final a = A<ObjectA>();
  final a2 = A<ObjectB>();

  test(ObjectA());
  test(ObjectB());

  final b = B<void Function(ObjectA)>();
  final b2 = B<void Function(ObjectB)>();
}

In the example, the class A accepts both ObjectA and ObjectB, but the class B does not.

My intuition is that, the class A will accept any generic that is at least ObjectA, which means it will also accept ObjectB. In the same way, the class B should accept any generic that is a Function where the first argument is at least ObjectA, which means it should also accept void Function(ObjectB) because ObjectB is at least ObjectA. It should, but it doesn't.

For this intuition to be applied with the current Function implementation, you need to declare the class B as:

class B<T extends ObjectA, T2 extends void Function(T)> { }

Which just leaves a lot of junk because it forces the instantiation to be:

final b = B<ObjectA, void Function(ObjectA)>();
eernstg commented 1 year ago

The reason why B<void Function(ObjectB)>() is a compile-time error is that the actual type argument void Function(ObjectB) violates the declared bound void Function(ObjectA), because the former is not a subtype of the latter.

The reason for this is that a formal parameter type of a function type is a contravariant position in the function type. So if you want to express a subtype of void Function(ObjectA) (and you want to do it by means of the parameter types) then you must use a supertype as the parameter type, e.g., void Function(Object?).

You can support the rules about variance by this intuition: If you want the function type S Function(T) to be a subtype of S0 Function(T0), then it must be the case that a function of type S Function(T) can be provided where a function of type S0 Function(T0) is expected. But this means that the function must be able to accept actual arguments of type T0, and this means that T0 must be a subtype of T; similarly, the function will return an S, and this is only OK when S is a subtype of S0. So parameter types must have the opposite subtype relation in order to make the function type substitutable. That's what contravariance is all about.

lrhn commented 1 year ago

The problem is that you are parameterizing MyClass with T, but its superclass with void Function(T). Those have opposite variances. And Dart does not allow that.

The T extends Function of its superclass is irrelevant. You can't do abstract class FunList<T> implements List<void Function(T)> {} either. The problem is the type MyClass itself having its type parameter be used contravariantly in its superinterface. That, by itself, is not allowed, independently of whether it would be a valid superinterface type argument otherwise.

And yes, it's because function parameters are contravariant.

If Dart had variance annotations, it would also be illegal to write:

class C<in T> {
  void add(T value) {}
}
class D<out T> extends C<T> {}

because it has the same unsound D<int> \<: D<num> \<: C<num>, so D<int>.add does not accept a double, but you can safely upcast it to a type which claims it should.

And you could try to you define MyClass as:

class MyClass<T extends void Function(Never)> extends CallbackObject<T>> { ... }

then you won't have any problem with co/contra-variance conflicts. You also won't have a type for the parameter, so you'll have a hard time calling the function.

Another version could be:

/// An [ObjectCallback] whose [ObjectCallback.function] accepts a [T].
class MyClass<T, F extends void Function(T)> extends ObjectCallback<F> { 
  MyClass(super.function);
  void invoke(T value) {
    function(value);
  }
}

Sadly I don't think type inference will find T and F for you, so, as you say, you have to provide them.