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 object
s.
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 out
going 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
in
coming 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 object
s 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 string
s along with int
s or struct
s directly. In
order to achieve that, you would need to box value types into reference types
manually.
If C# 9.0 is available, record
s 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, record
s 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.