In this article, we’re going to learn why covariance and contravariance are important, what they are, and how to use them in various scenarios using a few examples.
Let’s dive in.
Importance of Covariance and Contravariance in C#
When programming in C#, it’s important to know how to follow the inheritance of class definition hierarchies. For example, a GoldenRetriever
class derives from a Dog
class. A Dog
class derives from a Animal
class, and so forth.
Covariance and contravariance are important concepts to know because they allow us to be flexible when dealing with class definition hierarchies and assignment compatibility. They permit implicit reference conversion for various reference types in various scenarios.
What is Covariance in C#?
Covariance in C# is a concept of preserving assignment compatibility. It allows us to assign an object, variable, or parameter of a more derived type to an object, variable, or parameter of a less derived type.
It’s important to note that covariance is a concept that can occur implicitly, without using any special keywords or syntax.
Let’s look at a simple example:
public class Person { } public class Employee : Person { } public class Manager : Employee { } var personObject = new Person(); var employeeObject = new Employee(); var managerObject = new Manager(); personObject = employeeObject; personObject = managerObject;
As we can see, we can assign the more derived Employee
and Manager
objects to the less derived Person
object. This is the basic concept of inheritance, which allows us to substitute a Person
type with an Employee
or Manager
type or substitute an Employee
type with a Manager
type in most scenarios.
What is Contravariance in C#?
Contravariance in C# is the opposite of covariance. It allows us to reverse assignment compatibility preserved by covariance.
It’s important to note that the concept of contravariance applies more to parameters instead of objects and variables. If we try to use contravariance to implicitly assign an object of a less derived class type to an object of a more derived class type, our code won’t compile:
employeeObject = personObject; // Compile error: Cannot implicitly convert type 'Person' to 'Employee'. managerObject = personObject; // Compile error: Cannot implicitly convert type 'Person' to 'Manager'.
However, contravariance does allow us to assign a method with a parameter of a less derived type to a delegate with a parameter of a more derived type:
public class Person { } public class Employee : Person { } delegate void personDelegate(Employee employee); static void GreetPerson(Person person) { // Logic to greet person. }
The test method:
personDelegate del = GreetPerson; Assert.IsTrue(del.Method.ToString() == "Void GreetPerson(Tests.Person)");
Covariance and Contravariance in Arrays
Let’s see how we can use covariance and contravariance with arrays in C#.
Covariance in Arrays
C# supports covariance for arrays, but the operation is not always type-safe. If we try to implicitly convert an array of a more derived type to an array of a less derived type, our code will compile, but an exception will occur at runtime:
public class Person { } public class Employee : Person { } public class Manager : Person { } Person[] people = new Employee[5]; people[0] = new Manager(); // Runtime error: Attempted to access an element as a type incompatible with the array.
This occurs because the Employee
and Manager
classes derive from the Person
class. If we derive the Manager
class from Employee
, the operation becomes type-safe. The code compiles and runs without an error since it follows the class definition hierarchy:
public class Person { } public class Employee : Person { } public class Manager : Employee { } var managerObject = new Manager(); Person[] people = new Employee[5]; people[0] = managerObject;
Contravariance in Arrays
C# does not support contravariance for arrays. If we try to use contravariance with arrays, our code will not compile:
var personObject = new Person(); var employeeObject = new Employee(); var managerObject = new Manager(); Person[] people = { new Person() }; Employee[] employees = people; // Compile error: Cannot implicitly convert type 'Person[]' to 'Employee[]'.
Covariance and Contravariance in Delegates
When working with delegates, covariance and contravariance allow us to match delegate types with method signatures.
Covariance in Delegates
Covariance allows us to use a method that has a more derived return type from the method assigned to the delegate. This gives us more flexibility with the return types of delegate methods:
public class Employee { } public class Manager : Employee { } static Manager GetEmployeeManager(String employeeFullName) { // Logic to find employee's manager. return new Manager(); }
Test method:
Func<string, Manager> getManager = GetEmployeeManager; Func<string, Employee> getEmployee = GetEmployeeManager; getEmployee = getManager; Assert.AreEqual(getEmployee, getManager);
As we can see, covariance allows us to assign the GetEmployeeManager
method to the getEmployee
delegate, even though they have different return types. It also allows us to assign the getManager
delegate to the getEmployee
delegate. These operations are allowed since Manager
derives from Employee
.
Contravariance in Delegates
Contravariance in delegates applies to parameters. It allows us to assign a method with less derived parameters to a delegate that expects more derived parameters:
public class Employee { } public class Manager : Employee { } static void EvaluatePerformance(Employee employee) { // Logic to evaluate performance. }
Test method:
Action<Employee> evaluateEmployeePerformance = EvaluatePerformance; Action<Manager> evaluateManagerPerformance = EvaluatePerformance; evaluateManagerPerformance = evaluateEmployeePerformance; Assert.AreEqual(evaluateManagerPerformance, evaluateEmployeePerformance);
As we can see, contravariance allows us to assign the EvaluatePerformance
method to the evaluateManagerPerformance
delegate, even though they have different parameter types. It also allows us to assign the evaluateEmployeePerformance
delegate to the evaluateManagerPerformance
delegate. These operations are allowed since Manager
derives from Employee
.
Covariance and Contravariance in Generics
Similar to their other uses, applying covariance and contravariance to generic type parameters provides more flexibility when assigning and using types.
Covariance in Generics
We can use a covariant generic type parameter as a method return type. In C#, the out
keyword specifies that a generic type parameter is covariant:
public class Person { } public class Employee : Person { } interface ICovariant<out T> { } class ImplementICovariant<T> : ICovariant<T> { } ICovariant<Person> icovPerson = new ImplementICovariant<Person>(); ICovariant<Employee> icovEmployee = new ImplementICovariant<Employee>(); icovPerson = icovEmployee;
In this example, icovEmployee
can be assigned to icovPerson
since ICovariant
is covariant.
Contravariance in Generics
We can use contravariant generic type parameters as method parameter types. In C#, the in
keyword specifies that a generic type parameter is contravariant:
public class Person { } public class Employee : Person { } interface IContravariant<in T> { } class ImplementIContravariant<T> : IContravariant<T> { } IContravariant<Person> icontraPerson = new ImplementIContravariant<Person>(); IContravariant<Employee> icontraEmployee = new ImplementIContravariant<Employee>(); icontraEmployee = icontraPerson;
Here, icontraPerson
can be assigned to icontraEmployee
since the IContravariant
interface is contravariant.
Conclusion
In this article, we learned why covariance and contravariance are important, what they are, and how they are used with arrays, delegates, and generics.