C# Generics: Covariance, Contravariance and Robustness Principle

C# Generics: Covariance, Contravariance and Robustness Principle

Introduction

In C#, generics are used to create classes, methods, and interfaces where the type of data they operate on is not specified in advance. This allows for type safety without the overhead of multiple implementations. Here is some elementary example:

class Box<T>
{
    public T Content { get; private set; }

    public Box(T content) => Content = content;

    public override string ToString() => $"Box containing: {Content}";
}

class Apple
{
    public string Variety { get; set; }
    public override string ToString() => $"Apple of variety {Variety}";
}

It could be used in the following manner:

var intBox = new Box<int>(123);
Console.WriteLine(intBox); // Output: Box containing: 123

var stringBox = new Box<string>("Hello World");
Console.WriteLine(stringBox); // Output: Box containing: Hello World

var appleBox = new Box<Apple>(new Apple { Variety = "Fuji" });
Console.WriteLine(appleBox); // Output: Box containing: Apple of variety Fuji

Things become more interesting when there is a subtype relationship between classes. Suppose Fiji inherits from Apple:

class Apple { public override string ToString() => $"Apple"; }

class Fuji : Apple { public override string ToString() => $"Fuji"; }

Under normal circumstances you can rely on inheritance, casting and assignment compatibility:

var apple = new Apple();
var fuji = new Fuji();
apple = fuji;

Console.WriteLine(apple); // Output: Fuji

Guess what will happen when you try to do this trick with generics:

var appleBox = new Box<Apple>(new Apple());
var fujiBox = new Box<Fuji>(new Fuji());
appleBox = fujiBox; // error CS0029: Cannot implicitly convert type 'Box<Fuji>' to 'Box<Apple>'

Right. There is a compile-time error. It is a classic example of the invariance of generic type parameters in C#. By default, generic types in C# are invariant, meaning you cannot assign an instance of a generic type with one type parameter to a variable of the same generic type with a different type parameter, even if there is a subclass relationship between the two type parameters.

Assignment Compatibility

In C#, assignment compatibility refers to the ability to assign a value of one type to a variable of another type. This concept is crucial in type safety and closely related to inheritance and polymorphism principles. Assignment compatibility comes into play in several contexts, including:

  • Basic Type Assignment: A variable of a more general type (like an Apple) can hold a reference to an instance of a more specific type (like a Fuji).

  • Generics: With generic types, assignment compatibility is more complex. By default, generics are invariant. However, with covariance and contravariance (using out and in keywords), you can achieve assignment compatibility in certain cases.

Covariance

To fix the code and introduce covariance in the Box example, you would need to use an interface, as covariance and contravariance in C# are only supported in generic interfaces and delegates, not in classes.

First, create an interface IBox that is covariant in T using the out keyword. This interface will only allow methods that return T, not methods that accept T as a parameter:

interface IBox<out T>
{
    T Content { get; }
}

Now, make Box class implement the IBox interface:

class Box<T> : IBox<T>
{
    public T Content { get; private set; }

    public Box(T content) => Content = content;

    public override string ToString() => $"Box containing: {Content}";
}

Usage:

var appleBox = new Box<Apple>(new Apple());
var fujiBox = new Box<Fuji>(new Fuji());

// Covariant assignment: Box<Fuji> to IBox<Apple>
IBox<Apple> genericAppleBox = fujiBox; // No compile-time error
Console.WriteLine(genericAppleBox); // Output: Box containing: Fuji

In this example, genericAppleBox is of type IBox, and it can hold a reference to fujiBox, which is of type Box. This is allowed because of the covariance of IBox. Remember, this only works because we're using the interface IBox, not the class Box directly. The Box class itself remains invariant.

In C#, IEnumerable is a covariant interface, which means it allows for assignment compatibility in a subtype relationship. This means you can assign an IEnumerable of a more derived type (like string) to an IEnumerable of a less derived type (like object).

Here's the corrected example:

IEnumerable<object> objects;
IEnumerable<string> strings = new List<string>();

// This is allowed because IEnumerable<T> is covariant
objects = strings; // No compile-time error

This code compiles because IEnumerable is defined with the out keyword, making it covariant:

public interface IEnumerable<out T> : IEnumerable

The out keyword indicates that T is covariant, allowing an IEnumerable<string> to be assigned to an IEnumerable<object>. This is because every string is an object, so the assignment is safe and respects the type hierarchy.

Contravariance

Сontravariance is primarily concerned with input arguments, and it can feel a bit counter-intuitive at first. The grounding behind contravariance is that it allows a function to be more general in terms of what it accepts. In other words, if you have a method that can handle a more general (base) type, it can also handle all derived types. This is safe from a type safety perspective because the method expects nothing more than the base type's contract. To demonstrate contravariance in C#, I'll use a delegate example, as contravariance is commonly used with delegates and interfaces. Contravariance, marked with the in keyword, allows a method that has a parameter of a more general (less derived) type to be assigned to a delegate that expects a parameter of a more specific (more derived) type.

delegate void ActionHandler<in T>(T item);

Here, ActionHandler is contravariant in T due to the in keyword. This means that it can accept a delegate that handles a more general type than T.

class Fruit { }
class Apple : Fruit { }

void HandleFruit(Fruit fruit) => Console.WriteLine("Handling Fruit");

void HandleApple(Apple apple) => Console.WriteLine("Handling Apple");

ActionHandler<Apple> appleHandler = HandleFruit; // Contravariance
appleHandler(new Apple()); // Outputs: Handling Fruit

In this example, HandleFruit takes a Fruit as a parameter, which is a more general type than Apple. Despite this, it can be used where an ActionHandler is expected. This is possible because of the contravariant nature of the ActionHandler delegate. We are assigning a method that can handle any Fruit to a delegate that specifically expects to handle an Apple. This is safe because Apple is a subclass of Fruit, so the method HandleFruit can certainly handle an Apple.

Robustness Principle (Postel's Law)

Originally, Postel's Law, formulated by Jon Postel, was a guiding principle in the development of Internet protocols within the TCP/IP suite. It stated:

Be conservative in what you do, be liberal in what you accept from others.

In the context of TCP/IP, this meant:

  • Be Conservative in What You Do: When sending data, a system should strictly adhere to established protocols and standards, ensuring that what it transmits can be correctly interpreted by as many receivers as possible.

  • Be Liberal in What You Accept: When receiving data, a system should be tolerant of deviations from the standard. It should accept and try to interpret data even if it doesn't perfectly conform to the specifications, to maximize interoperability and robustness in real-world, imperfect network conditions.

This approach aimed to enhance the robustness and interoperability of the Internet. It encouraged the design of systems that could effectively communicate even when faced with variances in protocol implementation, leading to a more resilient network.

Robustness Principle and Generics

Covariance is about "be conservative in what you do": it ensures that what you return (or yield from a method) is at least as specific as what was promised. If you have an IEnumerable<Fuji>, you can assign it to an IEnumerable<Apple> because you're guaranteeing that every item produced by the IEnumerable<Fuji> will also be an instance of Apple.

Contravariance aligns with the "be liberal in what you accept" part of Postel's Law. With contravariance, you can use a method intended for a more general (less derived) type to handle a more specific (more derived) type. For example, a method that accepts a Fruit parameter can be used where an Apple is expected. You're being liberal in what you accept because you're allowing a broader range of inputs (any fruit, not just an apple).

Summary

The Robustness Principle guide for systems to be adaptable and flexible, yet predictable and reliable, is mirrored in the design of generic types in C#. Covariance and contravariance extend the principle to type compatibility, allowing for broader interoperability and reuse while ensuring type safety and clear expectations in software design.