Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Contravariance invalid when using interface's delegate as a parameter type

Consider the contravariant interface definition with a delegate:

public interface IInterface<in TInput>
{
    delegate int Foo(int x);
    
    void Bar(TInput input);
    
    void Baz(TInput input, Foo foo);
}

The definition of Baz fails with an error:

CS1961
Invalid variance: The type parameter 'TInput' must be covariantly valid on 'IInterface<TInput>.Baz(TInput, IInterface<TInput>.Foo)'. 'TInput' is contravariant.

My question is why? On first glance this should be valid, as the Foo delegate has nothing to do with TInput. I don't know if it's the compiler being overly conservative or if I'm missing something.

Note that normally you wouldn't declare a delegate inside an interface, in particular this doesn't compile on versions older than C# 8, since a delegate in an interface needs default interface implementations.

Is there a way to break the type system if this definition was allowed, or is the compiler conservative?

like image 303
V0ldek Avatar asked Feb 27 '21 17:02

V0ldek


1 Answers

TL;DR; This is correct according to the ECMA-335 spec, confusingly there are some situations when it does work

Assume we have two variables

IInterface<Animal> i1 = anInterfaceAnimalValue;
IInterface<Cat>    i2 = anInterfaceCatValue;

We can make these calls

i1.Baz(anAnimal, j => 5);
//this is the same as doing
i1.Baz(anAnimal, new IInterface<Animal>.Foo(j => 5));

i1.Baz(aCat, j => 5);
//this is the same as doing
i1.Baz(aCat, new IInterface<Animal>.Foo(j => 5));


i2.Baz(aCat, j => 5);
//this is the same as doing
i2.Baz(aCat, new IInterface<Cat>.Foo(j => 5));

If we now assign i1 = i2; then what happens?

i1.Baz(anAnimal, j => 5);
//this is the same as doing
i1.Baz(anAnimal, new IInterface<Animal>.Foo(j => 5));

But IInterface<Cat>.Baz (the actual object type) does not accept IInterface<Animal>.Foo, it only accepts IInterface<Cat>.Foo. The fact that these two delegates are the same signature does not take away from them being different types.


Let's go into it a bit deeper

Let me preface this with two points:

Firstly, remember that co-variant generic types in interfaces can appear in output positions (this allows a more derived type), and contra-variant in input positions (allows a more base type).

Covariance and contravariance in generics

In general, a covariant type parameter can be used as the return type of a delegate, and contravariant type parameters can be used as parameter types. For an interface, covariant type parameters can be used as the return types of the interface's methods, and contravariant type parameters can be used as the parameter types of the interface's methods.

With type parameters of the arguments you pass in, it's somewhat confusing: if T is covariant (output), a function can use void (Action<T>) which looks like it's an input, and can accept a delegate which is more derived. It can also return Func<T>.

If T is contra-variant the opposite is true.

See this excellent post by the great Eric Lippert and on the same question by Peter Duniho for further explanation on this point.

Secondly, ECMA-335, which defines the spec of the CLI, says the following (my bold):

II.9.1 Generic type definitions

The generic parameter is in scope in the declarations of:

  • snip...
  • all members (instance and static fields, methods, constructors, properties and events) except nested classes. [Note: C# allows generic parameters from an enclosing class to be used in a nested class, but adds any required extra generic parameters to the nested class definition in metadata. end note]

So nested types, of which the Foo delegate is an example, actually don't have the generic T type in scope. The C# compiler adds them in.


Now, see the following code, I have noted which lines do not compile:

public delegate void FooIn<in T>(T input);
public delegate T FooOut<out T>();

public interface IInterfaceIn<in T>
{
    void BarIn(FooIn<T> input);     //must be covariant
    FooIn<T> BazIn();
    void BarOut(FooOut<T> input);
    FooOut<T> BazOut();             //must be covariant

    public delegate void FooNest();
    public delegate void FooNestIn(T input);
    public delegate T FooNestOut();
    
    void BarNest(FooNest input);        //must be covariant
    void BarNestIn(FooNestIn input);    //must be covariant
    void BarNestOut(FooNestOut input);  //must be covariant
    FooNest BazNest();
    FooNestIn BazNestIn();
    FooNestOut BazNestOut();
}

public interface IInterfaceOut<out T>
{
    void BarIn(FooIn<T> input);
    FooIn<T> BazIn();               //must be contravariant
    void BarOut(FooOut<T> input);   //must be contravariant
    FooOut<T> BazOut();
    
    public delegate void FooNest();
    public delegate void FooNestIn(T input);
    public delegate T FooNestOut();
    
    void BarNest(FooNest input);        //must be contravariant
    void BarNestIn(FooNestIn input);    //must be contravariant
    void BarNestOut(FooNestOut input);  //must be contravariant
    FooNest BazNest();
    FooNestIn BazNestIn();
    FooNestOut BazNestOut();
}

Let's stick to IInterfaceIn for the moment.

Take the invalid BarIn. It uses FooIn, whose type parameter is covariant.

Now, if we have anAnimalInterfaceValue then we can call BarIn() with a FooIn<Animal> argument. This means that the delegate takes an Animal argument. If we then cast it to IInterface<Cat> then we could call it with a FooIn<Cat>, which demands a parameter of type Cat, and the underlying object is not expecting such a strict delegate, it expects to be able to pass any Animal.

So BarIn can therefore only use a type which is the same or less derived than what is declared, therefore it cannot receive the T of IInterfaceIn which may end being more derived.

BarOut however, is valid because it uses FooOut, which has a contra-variant T.

Now let's look at FooNestIn and FooNestOut. These actually re-declare the T parameter of the enclosing type. FooNestOut is invalid because it uses the co-variant in T in an output position. FooNestIn is valid though.

Let's move on to BarNest, BarNestIn and BarNestOut. These are all invalid, because they use delegates which have a co-variant generic parameter. The key here is that we do not care if the delegate actually uses the type parameter in a necessary position, what we care about is whether the variance of the generic parameter of the delegate matches the type that we are supplying.

Aha, you say, but then why do the IInterfaceOut nested parameters not work?

Let's look at ECMA-335 again, where it talks about generic parameters being valid, and asserts that each part of the generic type must be valid (my bold, S refers to a generic type e.g. List<T>, T means a type parameter, var means the in/out of the respective paramtere):

II.9.7 Validity of member signatures

Given the annotated generic parameters S = <var_1 T_1, ..., var_n T_n>, we define what it means for various components of the type definition to be valid with respect to S. We define a negation operation on annotations, written ¬S, to mean “flip negatives to positives, and positives to negatives”

Methods. A method signature tmeth(t_1,...,t_n) is valid with respect to S if

  • its result type signature t is valid with respect to S; and
  • each argument type signature t_i is valid with respect to ¬S.
  • each method generic parameter constraint type t_j is valid with respect to ¬S. [Note: In other words, the result behaves covariantly and the arguments behave contravariantly...

So we flip the variance of the type used in the method arguments.

The upshot of all this is that it is never valid to use a nested co- or contra-variant type in a method argument position, because the required variance is flipped, and therefore will not match. Whichever way round we do it, it won't work.

Conversely, using the delegate in the return position always works.

like image 173
Charlieface Avatar answered Oct 22 '22 21:10

Charlieface