The main point of this article is about covariance and contravariance, which is one of the topics I encountered in the .NET interview process, and most cases, I could not get a complete answer.
I'll try to explain this topic, which was added in .NET 4.0 and is widely used in the .NET library, in as simple a language as possible.
Covariance and Contravariance appear mainly in the form of generic delegates and interfaces.
The main purpose of covariance and contravariance is object substitution, that is, to set a restriction on the derivation hierarchy of the generic type that we will create.
Before moving on to the topic, let's consider the conversion of types by a simple process of derivation.
Let's say we have a class hierarchy of the form given below:
public class Database {
public int DatabaseId {
get;
set;
}
public string ConnectionString {
get;
set;
}
public string DatabaseName {
get;
set;
}
public int DatabaseVersion {
get;
set;
}
public string Server {
get;
set;
}
}
/// <summary>
/// For T-SQL
/// </summary>
public class SqlDatabase: Database {
//additional properties/methods go here...
}
/// <summary>
/// For PostgreSql. Supports only versions < 10.0.0
/// </summary>
public class PostgreSqlDatabase: Database {
//additional properties/methods go here...
}
/// <summary>
/// For PostgreSql.Supports only versions > 10.0.0
/// </summary>
public class PostgreSqlDatabase2: Database {
//additional properties/methods go here...
}
Based on this hierarchy, let's look at the conversion rules between parent and child classes:
//from the inheritance perspective it is correct
//because parent knows whos are its children
Database db1 = new SqlDatabase();
Database db2 = new PostgreSqlDatabase();
Database db3 = new PostgreSqlDatabase2();
//no conversion between children. Requires to implement explicit or implicit conversion
SqlDatabase sqlDatabase = new PostgreSqlDatabase();
//from the inheritance perspective it will not be compiled
//because child doesn't "know" about is its parent
SqlDatabase sqlDb = new Database();
As you can see from the examples above, any child class can be "raised" to the level of the parent class, but not the other way around.
Now let's look at such a code example:
static void Main(string[] args) {
object[] array = new string[3];
array[0] = "some text";
array[1] = "second text";
}
As most of us know, the above code example is fully functional. According to the derivation hierarchy, the object acts as the parent type of all types. In this case, we can "raise" the string array to the level of the parent class array (object[]). Here, the process of converting and encapsulating Child types to the parent type level is called covariance. All arrays are covariant.
In covariant processes, the left side of the assignment operation is the parent type, and the right side is the child type. However, covariance is not always a recommended form of behavior. Let's evaluate the following code:
public class User {
public string Name {
get;
set;
}
public byte Age {
get;
set;
}
public string Email {
get;
set;
}
}
class Program {
static void Main(string[] args) {
//covariance goes here...
object[] array = new User[3];
array[0] = new User() {
Age = 45, Email = "[email protected]", Name = "Test"
};
// it is not always a good idea to use covariance....
// facing with error...
array[1] = "and booom....";
}
}
Many interfaces appear in a covariant form on the .NET platform. If you see the keyword out in the generic type section of an interface or delegate, this indicates that the interface or delegate is covariant. For example, look at the System.Collections.Generic namespace through the Object Browser.
As we can see, the familiar IEnumerable and IEnumerator generic interfaces are also covariant!!
//covariance works from CHILD to PARENT
IEnumerable < Database > covariantdatabases = new List < SqlDatabase > ();
//covariance works from CHILD to PARENT. It will not be compiled!
IEnumerable < SqlDatabase > covariantdatabases2 = new List < Database > ();
//contravariance works from PARENT to CHILD
IEnumerable < Database > covariantdatabases = new List < SqlDatabase > ();
//covariance works from CHILD to PARENT. It will not be compiled!
IEnumerable < SqlDatabase > covariantdatabases2 = new List < Database > ();
To better illustrate the point, let's create a covariant interface and see the constraints that apply to it:
public interface IDataProducer < out T > {
T ProduceData();
}
//covariance works from CHILD to PARENT ! no way down!
IDataProducer<Database> producer = null!;
Database db_1 = producer.ProduceData(); //same level
SqlDatabase sqldb_1 = producer.ProduceData(); // moving down to CHILD fails
//covariance works from CHILD to PARENT.
//If type is derived then it is possible to step up to PARENT
IDataProducer<SqlDatabase> secondProducer = null!;
Database db_2 = secondProducer.ProduceData(); // moving to parent
SqlDatabase sqldb_2 = secondProducer.ProduceData(); // same level
In the covariance process, you cannot use a type as an argument to the method because the purpose is to produce the type, not to consume it!
Covariance in practice
We have an interface for projecting a payment terminal. This interface allows you to operate on different types of person class inheritance.
public interface ITerminal < T > {
void Pay(decimal amount, T user);
void Check(T user);
}
If the interface written above does not have any (here in/out ) restriction keyword in front of the generic type, then this interface is called invariant. The above ITerminal interface is also invariant. I will talk about this later.
But for now, let's focus on covariance. Let us make the interface covariant. Is it enough to just write out to make an interface covariant? No
If we want to covariate this interface:
//covariance doesn't allow to accept the generic type as an argument!!
//you can only produce, not consume!!
public interface ITerminalC < out T > {
void Pay(decimal amount, User user);
T Check(User user);
}
Now let's derive from the interface:
internal class PaymentTerminal < T > : ITerminal < T > where T: User, new() {
public T Check(User user) {
throw new NotImplementedException();
}
public void Pay(decimal amount, User user) {
throw new NotImplementedException();
}
}
public class User {
public string Name {
get;
set;
}
public byte Age {
get;
set;
}
public string Email {
get;
set;
}
}
public class Worker: User {
public string WorkBookNumber {
get;
set;
}
public decimal Salary {
get;
set;
}
}
class Program {
static void Main(string[] args) {
//covariance allows us to do it like this!!!
ITerminal < User > paymentTerminal = new PaymentTerminal < Worker > ();
}
}
static void Main(string[] args) {
//success conversion thanks to covariance!!!
ITerminal < User > paymentTerminal = new PaymentTerminal < Worker > ();
//but it fails.class can't select its covariance. for that reason, below code throws exception
PaymentTerminal < User > paymentTerminal = new PaymentTerminal < Worker > ();
}
The reason is that covariant-contravariant issues are an interface and delegate level process while we try to operate directly at the class level.
Our PaymentTerminal class is generic but invariant, so the code above doesn't work.
In the end, Covariance is finally indicated by the out keyword. Although a covariant interface cannot take a generic type as an argument, it can return that type as a result. Covariance is used in the cases of converting the child type to the parent type in the case of subordination to the derivative hierarchy.
Microsoft interfaces such as IEnumerable, IEnumerator, IQueryable, and IGrouping are covariant.
Contravariance
Contrary to covariance, contravariance is the process by which a child can transform into a child rather than a child into a parent...
Contravariant types are created using the in a keyword and, unlike covariance, do not return a result, passing a generic type as an argument.
Microsoft's IComparable, IObserver, IComparer, IEqualityComparer generic interfaces, and Action generic delegate are examples of contravariance.
Let's edit our code example:
public interface ITerminal < in T > {
void Pay(decimal amount, T user);
User Check(T user);
}
static void Main(string[] args) {
//it fails on contravariance!!!
ITerminal < User > paymentTerminal = new PaymentTerminal();
//converting from parent to child is succeed!!!
ITerminal < Worker > otherPaymentTerminal = new PaymentTerminal < User > ();
//but it fails
PaymentTerminal < Worker > _paymentTerminal = new PaymentTerminal < User > ();
}
static void Main(string[] args) {
// Create an array of worker objects.
Worker[] arrayOfUsers = new Worker[4] {
new Worker {
Age = 34, Email = "[email protected]", Name = "Test1"
},
new Worker {
Age = 24, Email = "[email protected]", Name = "Test2"
},
new Worker {
Age = 44, Email = "[email protected]", Name = "Test3"
},
new Worker {
Age = 55, Email = "[email protected]", Name = "Test4"
},
};
//user UserComparer to Compare Workers ....
Array.Sort(arrayOfUsers, new UserComparer());
}
//for comparing Workers we can use UserComparer ...
internal class UserComparer: IComparer < User > {
public int Compare(User x, User y) {
throw new NotImplementedException();
}
}
As we can see, although the array is of Worker type, we used Comparer's parent class type comparer implementation.
Let's look at our IDataConsumer interface to explain the topic corresponding to the IDataProducer interface. This interface will help you better understand contravariance. Contravariance implies consumption (consume). You can consume the type, but you cannot produce it.
// Only consume
public interface IDataConsumer < in T > where T: class {
void ConsumeData(T obj);
}
//contravariance works from PARENT to CHILD.no way up!
IDataConsumer < Database > consumer = null!;
consumer.ConsumeData(new Database());
consumer.ConsumeData(new SqlDatabase());
//contravariance works from PARENT to CHILD.no way up!
//If the type is derived then no possibility to step down to PARENT!!
IDataConsumer < SqlDatabase > consumer2 = null!;
consumer2.ConsumeData(new Database()); // moving up to PARENT will fail
consumer2.ConsumeData(new SqlDatabase());
Invariance
Most of the interfaces and delegates we use are invariant. If a generalized type is not specified with the in/out keywords, then it is invariant. In practice, we use the most common types of generalizations.
In invariant types, whatever type the declaration is in, the continuation must be specified in that type.
static void Main(string[] args) {
//List is not covariance, it will not work!
List users1 = new List < Worker > ();
//List is not contravariance, code will not work!
List < Worker > users2 = new List < User > ();
//List is invariance,it is working because operaion is between the same types
List < User > users3 = new List < User > ();
}