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

2

10

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?

Houseline answered 27/2, 2021 at 17:4 Comment(0)
P
4

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.

Plagiarism answered 27/2, 2021 at 19:36 Comment(12)
You can ignore that. Without the actual declarations, I thought they were interface types, not delegates. Also, I'm not awake enough to say this with authority, but I think that the distinction you make about delegate types, is really a distinction that exists for any generic type with a variant parameter when it itself is being used in a type-variant scenario. I.e. the thing that reverses the co/contra aspect is the extra level of indirection via the parameter or return value, not that it's a delegate or interface per se. (Intuitively this makes sense to me, since delegates are really ...Dennis
... just single-member interfaces with simpler syntax.) (sigh...just over again)Dennis
Found a link to Eric L, EDIT ooh you wrote something also. You're right I think. Except about them being single member.... :-)Plagiarism
Yeah, pretty much anything Lippert writes on the topic is going to be the gold standard. He always knows this stuff, but more importantly, 99.94% of the time he has a unique clarity in the way he expresses the facts that make it so much easier to understand than if anyone else wrote it. (Not sure what you object about my characterization of delegates...that I compared them to interfaces at all, or that they are equivalent to an interface with more than one member? Please note that I don't intend to mean they are the same to the runtime...just that semantically they're equivalent.)Dennis
Them being more than one member. See ECMA-335 II.14.6.3, and a decompiler obviously also. Admittedly, ECMA doesn't require async methods, but MS's CLI implementation does do so.Plagiarism
"Them being more than one member" -- ah, you just misunderstand me. As I noted above, I'm not talking about the mechanical (implementation details) aspects of a delegate, only the semantics. You can only "implement" a single "interface" member with a delegate type. It has more flexibility than an interface of course, because any code anywhere can "implement" the member. But it can only represent a single "interface" member.Dennis
Yeah, I knew already that's what you meant, just me being pedantic. I wonder what @EricLippert has to say about this, any way we can ping him?Plagiarism
Sure...his blog site has contact info.Dennis
Thanks for your kind words Peter; I know this stuff reasonably well mostly because I wrote the specification and implemented it in the compiler. :) As for what I have to say about this answer: it looks very complete and well-argued but I have not got the time today to go through it point by point to make a detailed critique. If you have a specific question I'm happy to consider it but I likely will not get to it until tomorrow. Busy day, chipping the pieces of a compiler out of the compiler mines.Eskil
@EricLippert Yeah I'd appreciate your thoughts. I can't quite put my finger on what's wrong with that last bit re IInterfaceOut, but something doesn't feel quite right. Why does IInterfaceOut.BarIn work but IInterfaceOut.BarNest and BarNestIn do not? I don't think even I fully understand why it does that. Happy for you to edit my post directly if you want. I also originally put more emphasis on the nested re-declaration, which I've since realized is actually mostly irrelevant.Plagiarism
@Charlieface: I left Microsoft well before the delegates-in-interfaces feature was added but I might be able to remember enough about the design proposals we discussed back then to figure it out. Not today though!Eskil
@EricLippert Have you had a chance to look this over yet?Plagiarism
A
0

I am not sure if this is co- versus contravariance problem.

  1. The Foo delegate is not a member of the interface. It is a nested type declaration.
  2. IInterface<A>.Foo and IInterface<B>.Foo are two different types.
  3. This makes the foo parameter of two different IInterface<T>.Baz methods (with T = A and B) incompatible.
  4. Therefore you cannot substitute a IInterface<A> for a IInterface<B> or vice-versa (no matter what the inheritance relationship between A and B is.
  5. Conclusion: IInterface<T> cannot be variant (neither co- nor contra-).

Resolution:

  • Move the delegate to the top level (in the body of a namespace). It is a type declaration, so, it does not need to be embedded.
  • Or embed it in a type with no type parameter. E.g., you could create a non-generic IInterface for this (and keep your generic one).

But @EricLippert certainly knows better.

Alliteration answered 1/3, 2021 at 14:46 Comment(5)
I hope the @EricLippert in my text will phone home :-)Alliteration
Given your conclusion, it seems this is a variance problem, so I think you should remove your first sentence.Maccabees
@Maccabees okay, I modified my sentence slightly.Alliteration
If it is not a variance issue (meaning that IInterface<Cat>.Foo is never assignment-compatible to IInterface<Animal>.Foo then how do you explain the FooNest BazNest() example that I gave, which does compile? By the way, not sure what your downvote was, did I say something wrong?Plagiarism
@Charlieface: I didn't downvote (I actually upvoted yesterday). You may be right. My feeling was the nested types wouldn't participate in the variance of the surrounding type.Alliteration

© 2022 - 2024 — McMap. All rights reserved.