Why does the variance of a class type parameter have to match the variance of its methods' return/argument type parameters?
Asked Answered
C

3

11

The following raises complaints:

interface IInvariant<TInv> {}
interface ICovariant<out TCov> {
    IInvariant<TCov> M(); // The covariant type parameter `TCov'
                          // must be invariantly valid on
                          // `ICovariant<TCov>.M()'
}
interface IContravariant<in TCon> {
    void M(IInvariant<TCon> v); // The contravariant type parameter
                                // `TCon' must be invariantly valid
                                // on `IContravariant<TCon>.M()'
}

but I can't imagine where this wouldn't be type-safe. (snip*) Is this the reason why this is disallowed, or is there some other case which violates type safety which I'm not aware of?


* My initial thoughts were admittedly convoluted, but despite this, the responses are very thorough, and @Theodoros Chatzigiannakis even dissected my initial assumptions with impressive accuracy.

Alongside a good slap from retrospect, I realize that I had falsely assumed that the type signature of ICovariant::M remains a Func<IInvariant<Derived>> when its ICovariant<Derived> is assigned to a ICovariant<Base>. Then, assigning that M to Func<IInvariant<Base>> would look fine coming from an ICovariant<Base>, but would of course be illegal. Why not just ban this last, obviously-illegal cast? (so I thought)

I feel this false and tangential guess detracts from the question, as Eric Lippert also points out, but for historical purposes, the snipped part:

The most intuitive explanation to me is that, taking ICovariant as an example, the covariant TCov implies that the method IInvariant<TCov> M() could be cast to some IInvariant<TSuper> M() where TSuper super TCov, which violates the invariance of TInv in IInvariant. However, this implication doesn't seem necessary: the invariance of IInvariant on TInv could easily be enforced by disallowing the cast of M.

Connective answered 17/5, 2016 at 14:27 Comment(1)
If you would like to know what the actual rules are, see blogs.msdn.microsoft.com/ericlippert/2009/12/03/…Biographical
B
5

I'm not sure that you actually got your question answered in either of the answers so far.

Why does the variance of a class type parameter have to match the variance of its methods' return/argument type parameters?

It doesn't, so the question is based on a false premise. The actual rules are here:

https://blogs.msdn.microsoft.com/ericlippert/2009/12/03/exact-rules-for-variance-validity/

Consider now:

interface IInvariant<TInv> {}
interface ICovariant<out TCov> {
   IInvariant<TCov> M(); // Error
}

Is this the reason why this is disallowed, or is there some other case which violates type safety which I'm not aware of?

I'm not following your explanation, so let's just say why this is disallowed without reference to your explanation. Here, let me replace these types with some equivalent types. IInvariant<TInv> can be any type that is invariant in T, let's say ICage<TCage>:

interface ICage<TAnimal> {
  TAnimal Remove();
  void Insert(TAnimal contents);
}

And maybe we have a type Cage<TAnimal> that implements ICage<TAnimal>.

And let's replace ICovariant<T> with

interface ICageFactory<out T> {
   ICage<T> MakeCage();
}

Let's implement the interface:

class TigerCageFactory : ICageFactory<Tiger> 
{ 
  public ICage<Tiger> MakeCage() { return new Cage<Tiger>(); }
}

Everything is going so well. ICageFactory is covariant, so this is legal:

ICageFactory<Animal> animalCageFactory = new TigerCageFactory();
ICage<Animal> animalCage = animalCageFactory.MakeCage();
animalCage.Insert(new Fish());

And we just put a fish into a tiger cage. Every step there was perfectly legal and we ended up with a type system violation. The conclusion we reach is that it must not have been legal to make ICageFactory covariant in the first place.

Let's look at your contravariant example; it's basically the same:

interface ICageFiller<in T> {
   void Fill(ICage<T> cage);
}

class AnimalCageFiller : ICageFiller<Animal> {
  public void Fill(ICage<Animal> cage)
  {
    cage.Insert(new Fish());
  }
}

And now, the interface is contravariant so this is legal:

ICageFiller<Tiger> tigerCageFiller = new AnimalCageFiller();
tigerCageFiller.Fill(new Cage<Tiger>());

Once again we have put a fish into a tiger cage. Once again we conclude that it must have been illegal to make the type contravariant in the first place.

So now let's consider the question of how we know that these are illegal. In the first case we have

interface ICageFactory<out T> {
   ICage<T> MakeCage();
}

And the relevant rule is:

The return types of all non-void interface methods must be valid covariantly.

Is ICage<T> "valid covariantly"?

A type is valid covariantly if it is: 1) a pointer type, or a non-generic class... NOPE 2) An array type... NOPE 3) A generic type parameter type ... NOPE 4) A constructed class, struct, enum, interface or delegate type X<T1, … Tk> YES! ... If the ith type parameter was declared as invariant, then Ti must be valid invariantly.

TAnimal was invariant in ICage<TAnimal>, So T in ICage<T> must be valid invariantly. Is it? No. To be valid invariantly it must be valid both covariantly and contravariantly, but it is valid only covariantly.

Therefore this is an error.

Doing the analysis for the contravariant case is left as an exercise.

Biographical answered 17/5, 2016 at 20:7 Comment(0)
E
6

Let's look at a more concrete example. We'll make a couple implementations of these interfaces:

class InvariantImpl<T> : IInvariant<T>
{
}

class CovariantImpl<T> : ICovariant<T>
{
    public IInvariant<T> M()
    {
        return new InvariantImpl<T>();
    }
}

Now, let's assume that the compiler didn't complain about this and try to use it in a simple way:

static IInvariant<object> Foo( ICovariant<object> o )
{
    return o.M();
}

So far so good. o is ICovariant<object> and that interface guarantees that we have a method that can return an IInvariant<object>. We don't have to perform any casts or conversions here, everything is fine. Now let's call the method:

var x = Foo( new CovariantImpl<string>() );

Because ICovariant is covariant, this is a valid method call, we can substitute an ICovariant<string> wherever something wants an ICovariant<object> because of that covariance.

But we have a problem. Inside Foo, we call ICovariant<object>.M() and expect it to return an IInvariant<object> because that's what the ICovariant interface says it will do. But it can't do that, because the actual implementation we've passed actually implements ICovariant<string> and its M method returns IInvariant<string>, which has nothing to do with IInvariant<object> due to the invariance of that interface. They are completely different types.

Exurbanite answered 17/5, 2016 at 15:56 Comment(0)
B
5

I'm not sure that you actually got your question answered in either of the answers so far.

Why does the variance of a class type parameter have to match the variance of its methods' return/argument type parameters?

It doesn't, so the question is based on a false premise. The actual rules are here:

https://blogs.msdn.microsoft.com/ericlippert/2009/12/03/exact-rules-for-variance-validity/

Consider now:

interface IInvariant<TInv> {}
interface ICovariant<out TCov> {
   IInvariant<TCov> M(); // Error
}

Is this the reason why this is disallowed, or is there some other case which violates type safety which I'm not aware of?

I'm not following your explanation, so let's just say why this is disallowed without reference to your explanation. Here, let me replace these types with some equivalent types. IInvariant<TInv> can be any type that is invariant in T, let's say ICage<TCage>:

interface ICage<TAnimal> {
  TAnimal Remove();
  void Insert(TAnimal contents);
}

And maybe we have a type Cage<TAnimal> that implements ICage<TAnimal>.

And let's replace ICovariant<T> with

interface ICageFactory<out T> {
   ICage<T> MakeCage();
}

Let's implement the interface:

class TigerCageFactory : ICageFactory<Tiger> 
{ 
  public ICage<Tiger> MakeCage() { return new Cage<Tiger>(); }
}

Everything is going so well. ICageFactory is covariant, so this is legal:

ICageFactory<Animal> animalCageFactory = new TigerCageFactory();
ICage<Animal> animalCage = animalCageFactory.MakeCage();
animalCage.Insert(new Fish());

And we just put a fish into a tiger cage. Every step there was perfectly legal and we ended up with a type system violation. The conclusion we reach is that it must not have been legal to make ICageFactory covariant in the first place.

Let's look at your contravariant example; it's basically the same:

interface ICageFiller<in T> {
   void Fill(ICage<T> cage);
}

class AnimalCageFiller : ICageFiller<Animal> {
  public void Fill(ICage<Animal> cage)
  {
    cage.Insert(new Fish());
  }
}

And now, the interface is contravariant so this is legal:

ICageFiller<Tiger> tigerCageFiller = new AnimalCageFiller();
tigerCageFiller.Fill(new Cage<Tiger>());

Once again we have put a fish into a tiger cage. Once again we conclude that it must have been illegal to make the type contravariant in the first place.

So now let's consider the question of how we know that these are illegal. In the first case we have

interface ICageFactory<out T> {
   ICage<T> MakeCage();
}

And the relevant rule is:

The return types of all non-void interface methods must be valid covariantly.

Is ICage<T> "valid covariantly"?

A type is valid covariantly if it is: 1) a pointer type, or a non-generic class... NOPE 2) An array type... NOPE 3) A generic type parameter type ... NOPE 4) A constructed class, struct, enum, interface or delegate type X<T1, … Tk> YES! ... If the ith type parameter was declared as invariant, then Ti must be valid invariantly.

TAnimal was invariant in ICage<TAnimal>, So T in ICage<T> must be valid invariantly. Is it? No. To be valid invariantly it must be valid both covariantly and contravariantly, but it is valid only covariantly.

Therefore this is an error.

Doing the analysis for the contravariant case is left as an exercise.

Biographical answered 17/5, 2016 at 20:7 Comment(0)
C
1

Why does the variance of a class type parameter have to match the variance of its methods' return/argument type parameters?

It doesn't!

The return types and the argument types don't need to match the variance of the enclosing type. In your example, they need to be covariant for both enclosing types. It sounds counter-intuitive, but the reasons will become apparent in the explanation below.


Why your proposed solution isn't valid

the covariant TCov implies that the method IInvariant<TCov> M() could be cast to some IInvariant<TSuper> M() where TSuper super TCov, which violates the invariance of TInv in IInvariant. However, this implication doesn't seem necessary: the invariance of IInvariant on TInv could easily be enforced by disallowing the cast of M.

  • What you're saying is that a generic type with a variant type parameter could be assigned to another type of the same generic type definition and a different type parameter. That part is correct.
  • But you're also saying that in order to work around the potential subtyping violation problem, the apparent signature of the method should not change in the process. That's not correct!

For example, ICovariant<string> has a method IInvariant<string> M(). "Disallowing the cast of M" would mean that when ICovariant<string> is assigned to ICovariant<object>, it still retains the method with the signature IInvariant<string> M(). If that were allowed, then this perfectly valid method would have a problem:

void Test(ICovariant<object> arg)
{
    var obj = arg.M();
}

What type should the compiler infer for the type of the obj variable? Should it be IInvariant<string>? Why not IInvariant<Window> or IInvariant<UTF8Encoding> or IInvariant<TcpClient>? All of them can be valid, see for yourself:

Test(new CovariantImpl<string>());
Test(new CovariantImpl<Window>());
Test(new CovariantImpl<UTF8Encoding>());
Test(new CovariantImpl<TcpClient>());

Clearly, the statically known return type of a method (M()) can't possibly depend on an interface (ICovariant<>) implemented by the runtime type of the object!

So when the generic type is assigned to another generic type with more general type arguments, the member signatures that use the corresponding type parameters must necessarily change to something more general as well. There's no way around it if we want to maintain type safety. Now let's see what "more general" means in each case.


Why ICovariant<TCov> requires IInvariant<TInv> to be covariant

For a type argument of string, the compiler "sees" this concrete type:

interface ICovariant<string>
{
    IInvariant<string> M();
}

And (as we saw above) for a type argument of object, the compiler "sees" this concrete type instead:

interface ICovariant<object>
{
    IInvariant<object> M();
}

Assume a type that implements the former interface:

class MyType : ICovariant<string>
{
    public IInvariant<string> M() 
    { /* ... */ }
}

Notice that the actual implementation of M() in this type is only concerned with returning an IInvariant<string> and it doesn't care about variance. Keep this in mind!

Now by making the type parameter of ICovariant<TCov> covariant, you are asserting that ICovariant<string> should be assignable to ICovariant<object> like this:

ICovariant<string> original = new MyType();
ICovariant<object> covariant = original;

...and you are also asserting that you can now do this:

IInvariant<string> r1 = original.M();
IInvariant<object> r2 = covariant.M();

Remember, original.M() and covariant.M() are calls to the same method. And the actual method implementation only knows that it should return an Invariant<string>.

So, at some point during the execution of the latter call, we are implicitly converting an IInvariant<string> (returned by the actual method) to an IInvariant<object> (which is what the covariant signature promises). For this to happen, IInvariant<string> must be assignable to IInvariant<object>.

To generalize, the same relationship must apply for every IInvariant<S> and IInvariant<T> where S : T. And that's exactly the description of a covariant type parameter.


Why IContravariant<TCon> also requires IInvariant<TInv> to be covariant

For a type argument of object, the compiler "sees" this concrete type:

interface IContravariant<object>
{
    void M(IInvariant<object> v); 
}

And for a type argument of string, the compiler "sees" this concrete type:

interface IContravariant<string>
{
    void M(IInvariant<string> v); 
}

Assume a type that implements the former interface:

class MyType : IContravariant<object>
{
    public void M(IInvariant<object> v)
    { /* ... */ }
}

Again, notice that the actual implementation of M() assumes that it will get an IInvariant<object> from you and it doesn't care about variance.

Now by making the type parameter of IContravariant<TCon>, you are asserting that IContravariant<object> should be assignable to IContravariant<string> like this...

IContravariant<object> original = new MyType();
IContravariant<string> contravariant = original;

...and you are also asserting that you can now do this:

IInvariant<object> arg = Something();
original.M(arg);
IInvariant<string> arg2 = SomethingElse();
contravariant.M(arg2);

Again, original.M(arg) and contravariant.M(arg2) are calls to the same method. The actual implementation of that method expects us to pass anything that's an IInvariant<object>.

So, at some point during the execution of the latter call, we are implicitly converting an IInvariant<string> (which is what the contravariant signature expects from us) to an IInvariant<object> (which is what the actual method expects). For this to happen, IInvariant<string> must be assignable to IInvariant<object>.

To generalize, every IInvariant<S> should be assignable to IInvariant<T> where S : T. So we're looking at a covariant type parameter again.


Now you may be wondering why there is a mismatch. Where did the duality of covariance and contravariance go? It's still there, but in a less obvious form:

  • When you are on the side of the outputs, the variance of the referenced type goes in the same direction as the variance of the enclosing type. Since the enclosing type can be covariant or invariant in this case, the referenced type must also be covariant or invariant respectively.
  • When you are on the side of the inputs, the variance of the referenced type goes counter to the direction of the variance of the enclosing type. Since the enclosing type can be contravariant or invariant in this case, the referenced type must now be covariant or invariant respectively.
Cornelison answered 17/5, 2016 at 16:33 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.