Type variance is a somewhat difficult concept in programming, and one that you seldom need to actually know about, so I managed to ignore it for quite awhile until I finally decided that it was time to buckle down and understand it. There are many online guides, but the ones I read only got me so far. It wasn’t until I spent an afternoon playing with the code myself that everything clicked and the guides I had once scratched my head at finally made sense.
I had been missing a certain surface level understanding that I needed to be able to really read about the topic. This post won’t be a full in-depth explainer of the subject. Instead, I’ll try to get across the key points of what variance is and isn’t so that you’ll be able to use variance and grasp the deeper explanations.
Introduction
In C#, type variance (often just called “variance” in this context) is the ability or inability of an instance of a generic interface or generic delegate to be substituted with another whose type parameter is a more or less derived type.
Let’s break that down a bit:
- “a generic interface or generic delegate” – Variance only applies to generic interfaces and generic delegates. It does not apply to classes, structs, non-generic interfaces, or non-generic delegates.
- “to be substituted with another” – Variance only matters when assigning instances of the generic type to variables of the generic type or when they are passed as method parameters. This point will make more sense after the examples.
- “ability or inability” and “whose type parameter is a more or less derived type” – There are 3 types of variance in C#: covariance (can use a more derived type), contravariance (can use a less derived type), and invariance (must use the same type).
Type parameters are invariant by default. They are made covariant with the out
keyword and contravariant with the in
keyword.
Setup
We’ll use the Func<TOutput>
and Action<TInput>
delegates for our examples since they’re simple and familiar delegates that will let us see both co- and contravariance. These are their signatures. Note the out
keyword (covariant) on TOutput
and the in
keyword (contravariant) on TInput
.
delegate TOutput Func<out TOutput>();
delegate void Action<in TInput>(TInput input);
We’ll need two dummy classes for our examples. They are empty, since all that matters is that Derived
inherits from Base
. We will also need two methods: one that takes no input and returns a Derived
, and one that takes a Base
as input and returns nothing. The method bodies do not matter, only their signatures.
class Base { }
class Derived : Base { }
Derived ReturnDerived() { return new Derived(); }
void InputBase(Base input) { }
Lastly, let’s make worse versions of those Func
and Action
delegates. We’ll call them InvariantFunc
and InvariantAction
, and they’ll have the same signatures as Func
and Action
but without the out
and in
keywords. As their names imply, this will make them invariant.
delegate TOutput InvariantFunc<TOutput>();
delegate void InvariantAction<TInput>(TInput input);
Invariance and ordinary polymorphism
A big mistake I made when learning about variance is that I mistook ordinary polymorphism for co- and contravariance. To prevent you from making the same mistake, let’s take a look at what we can do with our invariant delegates:
void LegalInvariance()
{
InvariantFunc<Base> notCovariance = ReturnDerived;
Base aBaseClassInstance = notCovariance();
InvariantAction<Derived> notContravariance = InputBase;
notContravariance(new Derived());
}
This is all legal since it doesn’t need covariance or contravariance. This is just regular polymorphism.
Why this is ordinary polymorphism
A method that returns Derived
and takes no input can be assigned to notCovariance
even though notCovariance
is an InvariantFunc<Base>
. This is because every instance of Derived
is also an instance of Base
, so when notCovariance()
is called, it returns a valid Base
to assign to aBaseClassInstance
.
Similarly, a method that returns nothing and takes an instance of Base
as input can be assigned to notContravariance
even though notContravariance
is an InvariantAction<Derived>
. Again, this is because every instance of Derived
is also an instance of Base
, so when notContravariance(new Derived())
is called, the InputBase
method is being passed a valid instance of Base
.
Illegal invariance
This code is not legal, even though it essentially does the same thing as lines 3 and 6 of the LegalInvariance
method:
void IllegalInvariance()
{
InvariantFunc<Derived> returnDerived = ReturnDerived;
// illegal:
InvariantFunc<Base> needsCovariance = returnDerived;
InvariantAction<Base> inputBase = InputBase;
// illegal:
InvariantAction<Derived> needsContravariance = inputBase;
}
Just like before we’re assigning ReturnDerived
to an InvariantFunc<Base>
variable and assigning InputBase
to an InvariantAction<Derived>
variable, so why is it illegal this time around?
Recall point #2 in the introduction: “variance only matters when assigning instances of the generic type to variables of the generic type….” Line 5 attempts to assign an instance of InvariantFunc<Derived>
to an InvariantFunc<Base> variable, which requires covariance. Line 10 attempts to assign an instance of InvariantAction<Base>
to an InvariantAction<Derived>
variable, which requires contravariance.
Covariance and contravariance
So, after all that talk about invariance, let’s see covariance and contravariance in action:
void CovarianceAndContravariance()
{
Func<Derived> returnDerived = ReturnDerived;
// legal because TOutput in Func<TOutput> is covariant
Func<Base> usesCovariance = returnDerived;
Action<Base> inputBase = InputBase;
// legal because TInput in Action<TInput> is contravariant
Action<Derived> usesContravariance = inputBase;
}
This is the same as the IllegalInvariance
method but using Func
and Action
instead of InvariantFunc
and InvariantAction
. This code is now legal.
This is of course a trivial example of covariance and contravariance, but it gets the idea across.
What kind of variance should you use for your generic types?
So when should we make our type parameters covariant, contravariant, or invariant? Here are some rules:
- Recall point #1 in the introduction: “variance only applies to generic interfaces and generic delegates”. So if we’re not defining a generic interface or a generic delegate then it’s irrelevant.
- If a type parameter is used as any of the following then it must be invariant:
ref return
ref
argumentin
argumentout
argument
- If a type parameter is only used as output (method/delegate returns and
get
properties) and does not violate rule #2, then it can be covariant. Remember: “out
for output.” - If a type parameter is only used as input (method/delegate argument and
set
properties) and does not violate rule #2, then it can be contravariant. Remember: “in
for input.” - If a type parameter is used for both input and output then it must be invariant.
Co- and contravariance make code more flexible, so if we can make a type parameter co- or contravariant we should do it unless we have a reason not to. Now that you’re aware of them, you’ll probably start noticing in
and out
on type parameters all over .NET.
Conclusion and resources
I hope you came away with a better understanding of variance in C#. Leave a comment if this helped, if there are any errors I should correct, or if you’ve got a question.
Source code for these examples is freely available here.
For further reading, I recommend Vasil Kosturski’s blog post on the subject.
The Microsoft documentation is also very informative, but it’s rather dense and strangely spread across different parts of their documentation website. Here are two good entry points to it:
Lastly, if you’re interested in the theory of type variance, check out the Wikipedia entry.