A dog is an animal. Is a list of Dogs a list of Animals?
It depends on how you use it, and what need from it.
Generic Types
Generics are a type system feature which allows instantiating a generic type with type arguments.
A common example is List<T>
,
which stores instances of its type argument T
as a sequential collection.
List<int> numbers = new() {1, 2, 3};
int number = numbers[0]; // 1
List<T>
is mutable.
We can add items using its Add
method.
numbers.Add(4);
number = numbers[3]; // 4
Some generic classes are immutable.
IEnumerable<T>
is a generic sequence of T
, which provides a read-only view of a List<T>
whose entries come one at a time.
IEnumerable<T> sequence = numbers;
Assignment Compatibility
In an inheritance relationship there are (at least) two classes involved: the base class and the derived class. A derived class instance is always substitutable for a base class instance, so the derived class is said to be a subtype of the base class supertype.
class Animal
{
public virtual string GetNoise() => "???";
}
class Dog
{
public override string GetNoise() => "Woof!";
}
void SayNoise(Animal animal) {
Console.WriteLine(animal.GetNoise());
}
Animal animal = Dog();
SayNoise(animal); // "Woof!"
In this example Dog
is a subtype of Animal
.
Any time an Animal
is needed, a Dog
may be used instead.
That is to say, a Dog
is substitutable for an Animal
.
What about a collection of animals?
Is a list of Dog
s substitutable for a list of Animal
s?
Covariance
A generic type S
is said to be covariant if, given type arguments T
and U
where T
is a subtype of U
, S<T>
is a subtype of S<U>
.
For example, IEnumerable<Dog>
is a subtype of IEnumerable<Animal>
because Dog
is a subtype of Animal
and IEnumerable<T>
is covariant.
This makes sense. Since Dog
is always substitutable for Animal
,
a function observing a sequence of animals won’t mind if some or all of them are dogs.
void SayNoises(IEnumerable<Animal> animals) {
foreach (Animal animal in animals) {
SayNoise(animal);
}
}
List<Dog> dogList = new() {Dog(), Dog(), Dog()};
IEnumerable<Dog> dogs = dogList;
IEnumerable<Animal> animals = dogs;
SayNoises(animals);
Invariance
Now let’s imagine that I want to create a list containing other kinds of household pets.
To accommodate cats, we’ll create a Cat
class.
class Cat : Animal
{
public override string GetNoise() => "Meow!";
}
Let’s add them all to a list of pets.
void AddDogs(List<Animal> pets) {
pets.Add(Dog());
pets.Add(Dog());
pets.Add(Dog());
}
void AddCats(List<Animal> pets) {
pets.Add(Cat());
pets.Add(Cat());
}
List<Animal> pets = new();
AddDogs(pets);
AddCats(pets);
So far so good. Let’s take our dogList
from earlier and add more dogs to it.
AddDogs(dogList) // Error!
Wait, what? Why can’t I add dogs to a list of dogs?
The problem is that AddDogs
doesn’t work with List<Dog>
–
it works with List<Animal>
.
List<T>
is not covariant, it is invariant.
List<Dog>
is not a subtype of List<Animal>
in the way that
IEnumerable<Dog>
is a subtype of IEnumerable<Animal>
.
AddDogs
expects a List<Animal>
, for which List<Dog>
is not substitutable.
List<Dog>
and List<Animal>
are both subtypes of IEnumerable<Animal>
,
so either can be used wherever an IEnumerable<Animal>
is required.
However List<Dog>
cannot be used wherever a List<Animal>
is required.
But why does the subtype hierarchy look this way?
Why can’t List<Dog>
be a subtype of List<Animal>
directly,
instead of going through this IEnumerable
path?
Why isn’t List<T>
covariant?
It comes down to mutability.
Since List<T>
is mutable, we can add instances of T
to it.
Remember the rule about assignment compatibility:
we can substitute any instance of a subtype for an instance of the supertype.
Since Dog
and Cat
are both subtypes of Animal
,
we can add either to a List<Animal>
.
List<Animal> animals = new();
animals.Add(Dog());
animals.Add(Cat());
If List<T>
were covariant, List<Dog>
would be a subtype of List<Animal>
and the following would be possible:
List<Dog> dogs = new();
dogs.Add(Dog());
List<Animals> animals = dogs;
animals.Add(Cat()); // !!!
See the problem? animals
is just a reference to dogs
,
and we tried to add a Cat
to it.
List<Dog>
can’t store cats, it can only store dogs.
Therefore a mutable collection can never be covariant 1.
The C# compiler will not allow dogs
to be assigned
as a value to a variable of type List<Animal>
.
This problem goes away if the collection is immutable because it is now impossible
to add a cat to a list of dogs— there is no Add
method on IEnumerable<T>
.
Contravariance
This idea of covariance doesn’t just apply to container types;
it is relevant to any generic type with type parameters which have a subtype/supertype relationship.
Consider Action<T>
,
which represents a callable function with a single argument of type T
and no return value.
Action<Animal> speak = SayNoise;
Is Action<T>
covariant? Can we substitute Action<Dog>
for Action<Animal>
?
void DogSpeak(Dog dog)
{
Console.WriteLine($"The dog says {dog.SayNoise()}");
}
Action<Dog> speakDog = DogSpeak;
Action<Animal> speakAnimal = speakDog;
Animal cat = Cat();
speakAnimal(cat); // !!!
This won’t work. DogSpeak
requires a Dog
, and by substituting Action<Dog>
for Action<Animal>
we can now provide a Cat
instead.
The opposite could work though: a function expecting Animal
would be happy to receive a Dog
.
The Action<Dog>
interface forces callers to provide a subset of possible Animal
s
(i.e., Dog
s), but can’t add anything that a function expecting an Animal
would be unprepared to handle.
Action<Animal> speakAnimal = SayNoise;
Action<Dog> speakDog = speakAnimal;
Animal dog = Dog();
speakDog(dog);
Action<Animal>
is substitutable for Action<Dog>
!
They have an assignment compatibility relationship opposite that Animal
and Dog
do.
This reversal of subtype/supertype relationship in the generic type means that Action<T>
is contravariant.
Defining Covariance and Contravariance
This property of co- and contravariance isn’t something the compiler can determine automatically.
It is opt-in,
by declaring the type arguments as out
(covariant) or in
(contravariant).
The keywords provide a hint as to how the type argument may be used.
Covariant type arguments can only be outputs (i.e., method return types),
and contravariant type arguments can only be inputs (i.e., method parameter types).
interface ICovariant<out T>
{
T GetSomething();
}
interface IContravariant<in T>
{
void DoSomething(T arg);
}
Consider the following example
List<Dog> dogList = new() { Dog(), Dog() };
IEnumerable<Dog> dogSequence = dogList;
IEnumerable<Animal> animalSequence = dogSequence;
Action<Animal> animalAction = animal => Console.WriteLine(animal.SayNoise());
Action<Dog> dogAction = animalAction;
We can conceptualize the subtype relationships by imagining that the supertype “contains” the subtype. It doesn’t really, but we can think of the covariance and contravariance relationships as “wrapping” the inner type.
Here we see that the innermost List<Dog>
can “forward” its contents to the enclosing IEnumerable<Dog>
,
which can then forward them to the enclosing IEnumerable<Animal>
, because IEnumerable
is covariant.
The output of IEnumerable<Dog>
is a Dog
,
which we can use in an Action<Dog>
directly.
The output of IEnumerable<Animal>
is, in this case, a Dog
under the hood, but the IEnumerable<Animal>
interface hides this and presents the Animal
supertype instead.
This cannot be used in an Action<Dog>
, but can be used in Action<Animal>
. However because in this case our Action<Dog>
is itself a “wrapper” around an Action<Animal>
,
Dog
s passed to it are substituted for Animal
s in the enclosed Action<Animal>
.
Notice that arrows only ever leave IEnumerable
blocks
and only ever enter Action
blocks.
If it were possible to pass an Animal
into an IEnumerable<Animal>
,
then it couldn’t be covariant because an underlying object expecting Dog
s could receive arbitrary Animals
—
(just like a List<Dog>
could if used as a List<Animal>
).
Similarly, if it were possible to return an Animal
from an Action<Animal>
,
then it couldn’t be contravariant because it could return arbitrary Animal
s to callers expecting Dog
s.
Summary
- Covariance preserves assignment compatibility:
IEnumerable<Dog>
is usable in place ofIEnumerable<Animal>
, just asDog
is usable in place ofAnimal
. - Contravariance reverses assignment compatibility:
Action<Animal>
is usable in place ofAction<Dog>
. - Invariance breaks assignment compatibility:
List<Dog>
cannot be used in place ofList<Animal>
.
-
The problem is actually due to “input” mutability. A remove-only list is mutable but can still be covariant because there is never an opportunity to insert an invalid item type into the collection. I address this in more detail later. ↩︎