Hi Nigey, You will want to do some reading on covariance and contravariance. Here is my crash course:
Theory
Generics are an implementation of a concept called “type variance”. Variance on a type is a little bit like inheritance on a type but works at a more abstract level. While a class which inherits from a type might add functionality and data, a variant of a type can add whole new semantic meanings in addition to adding functionality or data. Variants can also remove semantic meanings and functionality so I think it’s more natural to think of it as just being a different entity entirely. However, there is some value in understanding that variants are closely related to their variant type.
I feel like that explanation was very abstract, so consider an example: List is a variant of int because it varies the integer class to behave differently. In this case it adds multiple cardinality, adding the ability to discuss set operations but removing the ability to discuss identity operations. You might think of List as a very different thing than int, but they are highly related.
If you want to know more about the theory, I recommend reading about Monads.
A little more practical
Let’s go deeper. There are four main types of variants:
- invariant
- covariant
- contravariant
- bivariant
Note Notice the name of category one. This is a weird overloading of the word invariant because it is actually a subtype of a variant rather than the opposite of a variant. I think this is why the word generic has become popularized over variant.
This categorization is based on the ordering of the variants type’s subtypes, that is to say: IVariant is categorized based on the subtyping of T.
By default in C#, your variants are considered invariant. That means you cannot change the “level” of the variant type, as you discovered. In other words, a List variable cannot hold a List.
Since C# 4, some interfaces are defined as covariant. This allows an upwards (more generic) subtyping of variant types. IEnumerable is defined as such, so IEnumerable can hold an IEnumerable.
Also since C# 4, some interfaces are defined as contravariant. This allows downwards (more specific) subtyping of variant types. Action is defined as such, so Action can hold an Action.
Bivariants allow both upwards and downwards subtyping of their variant types. I don’t think bivariants can exist in the current C# syntax.
Actually practical
Let’s dig into the why – consider this simple example.
public interface IPetStore<T> : where T : Animal
{
T GetPet(string name);
}
This interface is an example of a source because it only returns objects of its variant type. Because they do not accept instances of the variant type, sources have a special characteristic: the variant type will always fall into situations where it’s okay to refer to a more generic type. As a result, this kind of variant can be considered covariant, however C# does not consider it to be implicitly. You need to use the out keyword so the compiler knows you would like to treat this variant as covariant:
public interface IPetStore<out T> : where T : Animal
{
T GetPet(string name);
}
The compiler will now treat IPetStore as covariant and will allow the following:
IPetStore<Animal> store = new PetStore<Dog>();
When store.GetPet() is called, as you would expect, the concrete PetStore will always return a Dog. However, since the store is covariant, its okay to assume the item will be an animal.
Unfortunately, declaring the variant as covariant applies extra restrictions on the variant: it must remain a source. In other words, it can return instances of it’s variant type but not accept them as parameters.
Let’s move on to another example.
public interface IPound<T> where T : Animal
{
void LockUp(T animal);
}
This interface is an example of a sink because it only accepts objects of its variant type. Because they do not return instances of the variant type, sinks have a special characteristic: the variant type will always fall into situations where it’s okay to refer to a more specific type. As a result, this kind of variant can be considered contravariant, however C# does not consider it to be implicitly. You need to use the in keyword so the compiler knows you would like to treat this variant as contravariant:
public interface IPound<in T> where T : Animal
{
void LockUp(T animal);
}
The compiler will now treat IPound as contravariant and will allow the following:
IPound<Dog> pound = new Pound<Animal>();
Because of the casting, pound.LockUp() will only accept dogs and the concrete Pound will have no trouble accommodating it.
As you might have guessed, declaring the variant as contravariant also applies extra restrictions on the variant: it must remain a sink. In other words, it can accept instances of its variant type as parameters but not return them.