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.