Yet another explanation of variance in C#

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:

  1. “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.
  2. “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.
  3. “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:

  1. 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.
  2. If a type parameter is used as any of the following then it must be invariant:
    • ref return
    • ref argument
    • in argument
    • out argument
  3. 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.”
  4. 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.”
  5. 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.