Let's talk about Contravariance and Covariance in C# using .NET Framework examples!
Contravariance and covariance are essential concepts in C# when dealing with generics, enabling us to have more flexibility when assigning generic types. So let's have examples straight from the framework itself!
Also, we will go a bit deeper and talk what some differences between generic constraints and things like Contravariance are.
Covariance
Allows a method to return a more derived type than specified by the generic type. In other words, you can use a derived class where a base class is expected.
Example: The IEnumerable<out T>
interface. IEnumerable<T>
is covariant, meaning that if Dog
is a subclass of Animal
, you can use IEnumerable<Dog>
where IEnumerable<Animal>
is expected.
IEnumerable<Dog> dogs = new List<Dog> { new Dog(), new Dog() };
IEnumerable<Animal> animals = dogs; // Covariant assignment
Contravariance
Enables you to use a more generic (less derived) type than specified by the generic type. In other words, you can use a base class where a derived class is expected.
Example: The IComparer<in T>
interface. IComparer<T>
is contravariant, meaning that if Animal
is a base class of Dog
, you can use IComparer<Animal>
where IComparer<Dog>
is expected.
public class AnimalComparer : IComparer<Animal>
{
public int Compare(Animal x, Animal y) { /*...*/ }
}
IComparer<Dog> dogComparer = new AnimalComparer(); // Contravariant assignment
You might ask: What code does it make easier to use contravariance? Why not use generics in this case or directly the base type?
Contravariance simplifies code by allowing you to create more reusable and maintainable components, especially when working with delegates and interfaces. While generics alone provide type safety, contravariance offers an additional layer of flexibility.
Consider the following example using the base type directly:
public class Animal { }
public class Dog : Animal { }
public class AnimalHandler
{
public void Handle(Animal animal) { /*...*/ }
}
public class DogHandler : AnimalHandler
{
public void Handle(Dog dog) { /*...*/ }
}
Now imagine we have a method that takes a list of dogs and an AnimalHandler
:
public void ProcessDogs(List<Dog> dogs, AnimalHandler handler)
{
foreach (var dog in dogs)
{
handler.Handle(dog);
}
}
This works fine for an AnimalHandler
instance, but if you want to use a DogHandler
, you'll have to modify the ProcessDogs
method. This creates tight coupling and reduces reusability.
Now let's use contravariance with an interface:
public interface IHandler<in T>
{
void Handle(T item);
}
public class AnimalHandler : IHandler<Animal>
{
public void Handle(Animal animal) { /*...*/ }
}
public class DogHandler : IHandler<Dog>
{
public void Handle(Dog dog) { /*...*/ }
}
With contravariance, the ProcessDogs
method can accept an IHandler<Dog>
instead:
public void ProcessDogs(List<Dog> dogs, IHandler<Dog> handler)
{
foreach (var dog in dogs)
{
handler.Handle(dog);
}
}
Now, you can pass either an AnimalHandler
or a DogHandler
to ProcessDogs
, as both are compatible with IHandler<Dog>
. This promotes loose coupling, making the code more reusable and maintainable.
If I define my IHandler like this:
public interface IHandler<TAnimal> where TAnimal : Animal
Wouldn't that achieve the same?
Yes, using a constraint on the generic type achieves a similar goal. However, it's essential to understand the differences between using constraints and using contravariance to choose the best approach for your needs.
By defining IHandler<TAnimal>
with a constraint:
public interface IHandler<TAnimal> where TAnimal : Animal
{
void Handle(TAnimal item);
}
You can implement specialized handlers for each derived type:
public class AnimalHandler : IHandler<Animal>
{
public void Handle(Animal animal) { /*...*/ }
}
public class DogHandler : IHandler<Dog>
{
public void Handle(Dog dog) { /*...*/ }
}
However, the ProcessDogs
method will have to be defined as:
public void ProcessDogs<TAnimal>(List<TAnimal> animals, IHandler<TAnimal> handler) where TAnimal : Animal
{
foreach (var animal in animals)
{
handler.Handle(animal);
}
}
You can still call ProcessDogs
with either an AnimalHandler
or a DogHandler
:
ProcessDogs(new List<Dog> { new Dog(), new Dog() }, new AnimalHandler());
ProcessDogs(new List<Dog> { new Dog(), new Dog() }, new DogHandler());
The primary difference is that the ProcessDogs
method is now generic, and the type safety and flexibility come from the generic type constraint. In contrast, contravariance provides flexibility directly in the interface definition without the need for a generic method.
Conclusion
Covariance and contravariance help you to write more flexible and reusable code!