Skip to main content

C#: Interface Variance

C# is more tightly coupled than C++. Everything has its place in the class hierarchy. The hierarchy is mainly composed of interfaces and classes.

Interfaces provide contracts for the implementation's capabilities, which classes then fill with life. We all know the scary legends from multiple inheritance and how it brought the diamond problem over the world once. Interfaces saved us. If you are lucky enough to use .NET 5.0 or later, you are not even required to pay the tribute of implementing their methods for each single class. Let us explore them a little more.

Introduction

By default, generic interfaces are invariant. Whatever generic type the class takes, it needs to be the same for its interface.

IEnumerable<string> objects = new List<string>{};

That way we can abstract from the concrete type. We go up the hierarchy, sacrifice some abilities such as modifying the list and rely only on the required interface. This allows us to swap out the list with another collection and still access objects the same way.

Sometimes we want to go even further. Not only do we want to abstract List, but we also want to do something similar for the contained type. For example, we might need a container of objects.

var moreObjects
  = new List<FavoriteClass> { bestInstance, backupInstance };

IEnumerable<object> objects = new List<string> { "1", "2", "3" }
                            .Union<object>(moreObjects);

This is possible, since the container does not need to rely on contained type's specifics.

Sometimes we want to go even further in the other direction. Consider a method 1 that does not rely on the specifics of its parameter. We can not only encapsulate it in Action<object> but also in an Action which happens to provide a more specific string.

Action<object> action1 = (object o) => Console.WriteLine(o);
Action<string> action2 = action1;

In the collection example, we go up the hierarchy for the contained type. This is called covariance. In the action case, we go the hierarchy down for the contained type. This is called contravariance.

Interface<Base>    <- Implementation<Derived> /* Covariance.     */
Interface<Derived> <- Implementation<Base>    /* Contravariance. */

Let us bring this feature to our own code base.

Covariance

To define a covariant interface, we use the out keyword for the generic type.

interface Interface<out T> {}

Congratulations. We are done here. Now we can use it.

class Class<T> : Interface<T> {}

For some classes C1 and C2, covariance is now supported.

class C1      {}
class C2 : C1 {}

Interface<object> o1 = new Class<C2>();
Interface<C1>     o2 = new Class<C2>();
Interface<C2>     o3 = new Class<C2>();

Contravariance is still prevented.

Interface<C2> o4 = new Class<C1>(); /* Error. */

Contravariance

To define a contravariant interface, we use the in keyword for the generic type.

interface Interface<in T> {}

We can then implement the interface.

class Class<T> : Interface<T> {}

For some classes C1 and C2, contravariance is now supported.

class C1      {}
class C2 : C1 {}

Interface<C1> i1 = new Class<C1>();
Interface<C2> i2 = new Class<C1>();
Interface<C1> i3 = new Class<object>();

Covariance is prevented.

Interface<C1> i4 = new Class<C2>(); /* Error. */

Restrictions

Invariant generic interfaces are the default because covariance and contravariance come with restrictions. Covariant interfaces can only contain methods 2 with an outgoing T.

/* Covariance. */
interface OutI<out T>
{
  void method(T t) {}        /* Error. */
  T? method() => default(T); /* Fine. */
}

On the other hand, contravariant interfaces can only contain methods with an incoming T.

/* Contravariance. */
interface InI<in T>
{
  void method(T t) {}        /* Fine. */
  T? method() => default(T); /* Error. */
}

Naturally, this also applies to static methods and properties. However, methods of the implementing class are not restricted.

Those restrictions make sense in context of C#. If your covariant container holds objects of different kind, you can add a string to it. However, expecting to receive particularly a string from any such object might be not justified.

Similarly, if your Action holds a method to handle a general object, you can surely throw a string at it. However, if the contained method assumes properties of a string, an object might not satisfy those.

Therefore, one particular generic parameter is either invariant, covariant or contravariant. Different variance types are still possible for different generic parameters.

interface Interface<T1, in T2, in T3, out T4, out T5> {}

Variance for Value Types

Value types are not supported. Therefore, an IEnumerable<object> cannot contain strings along with ints or structs directly. In order to achieve that, you would need to box value types into reference types manually.

If C# 9.0 is available, records provide a better alternative. Those provide value semantics with facilitated syntax, while being reference types.

record R (int value);

IEnumerable<object> test
  = new List<string> { "1", "2", "3" }
  .Union<object>(new List<R> { new(4), new(5), new(6) });

Conclusion

Things are more loosely coupled in C++. std::vector and std::list are not derived from each other. You can throw anything into std::accumulate() that supports its constraints. We would use variants or type erasure to hold even unrelated types, while having polymorphism and covariant return types 3 as backup.

With the more tight coupling in C#, interface variance is more important. For my taste, the feature might have allowed a bit more control by supporting value types, but this restriction comes rather from generic interfaces than from this particular feature. Also, records relieve you from considering boxing, mutability and performance for value types in context of interfaces.

As conclusion, interface covariance and contravariance is a fairly solid feature to adjust the capabilities of your interfaces. Make use of it.


1

You might wonder whether contravariance applies to lambdas directly.

Action<string> action_fine = (string s) => Console.WriteLine(s);

void method(object o) => Console.WriteLine(o);
Action<string> action_finer = method;

Action<string> action_fail = (object o) => Console.WriteLine(o);

C# being C#, the short answer is “no”.

2

Note that we are using a default implementation in the interface. If .NET 5.0 is not available to you, you need to implement the methods in the class instead.

3

Covariant return types are followed up by C# 9.0.