Type Variance
In type theory, a type can be:
- Covariant
- Contravariant
- Invariant
- Bivariant
What does that mean?
Covariant types and the is a relationship between classes
This is a simple illustration of Covariant and Contravariant cases.
Before we start, we need to define the Covariant Assignment Principle
Covariant Assignment Principle
Let D be a derived class from B. Then, the following holds:
B* base = new D()is a valid assignment.D* derived = new B()is a non-valid assignment.
The hierarchy D:B means that instances of D are also instances of B.
Consider an example with Animal, Cat and Horse. The first item in the principle says that we can abstract any instance of cat as an animal. This is reasonable because a cat is an animal, that is, a cat has all the attributes to be considered as an animal.
The second item in the principle states that we cannot abstract a general animal to a specific one. That is reasonable as well. We cannot say that a horse is a cat although both are animals.
Whenever the types in a class hierarchy respects the covariant principle, they are said to be covariant.
class Animal{
virtual void make_noise() = 0;
};
class Cat: public Animal{
void make_noise();
};
class Horse: public Animal{
void make_noise();
void ride();
};
int main(){
Animal* a = new Cat();
// Horse* h = a; // Does not work
}
Therefore, Cat (Horse) and Animal are covariant. But that's not always the case. Two
types in a hierarchy might follow the exact opposite of the covariant rule.
Contravariant types
Yes, you guessed right! If types in a hierarchy follow the opposite of the covariant principles, then they follow the contravariant principle.
Contravariant Assignment Principle
Let D be a derived class from B. Then, the following holds:
B* base = new D()is a non-valid assignment.D* derived = new B()is a valid assignment.
Producer and Consumer interfaces
To give an example of contravariant types, let us define two function interfaces.
#include <functional>
using AnimalBreeder = function<Animal*(void)>;
using AnimalDoctor = function<void(Animal*)>;
Notice that the AnimalBreeder produces Animals while the AnimalDoctor consumes them. These
two functions define a Producer/Consumer interface. Let us define their derived types.
using CatBreeder = function<Cat*(void)>;
using HoserBreeder = function<Horse*(void)>;
using CatDoctor = function<void(Cat*)>;
using HorseDoctor = function<void(Horse*)>;
CatBreeder cb = [](){return new Cat();};
AnimalBreeder ab = cb;
// HorseBreeder hb = ab; // Does not work
CatDoctor cd = [](Cat* cat){cout << "Hey, cat!";};
// AnimalDoctor ad = cd; // Does not work
AnimalDoctor ad = [](Animal*){cout << "Hey, animal!";};
HorseDoctor hd = ad;
The types CatBreeder (HorseBreeder) and AnimalBreeder are covariant because a
CatBreeder (HorseBreeder) is an AnimalBreeder. The CatBreeder (HorseBreeder)
produces cats (horses) and cats are animals.
The types CatDoctor (HorseDoctor) and AnimalDoctor are contravariant because
a CatDoctor (HorseDoctor) is not an AnimalDoctor. An AnimalDoctor is
an entity that knows how to treat all the animals that exist. A CatDoctor
(HorseDoctor) only knows how to treat cats (horses).
Important
It might feel a little artificial the explanation of contravariance above
in the terms of: CatDoctor is not an AnimalDoctor. The key is in the fact
that CatDoctor is a function that consumes a type Cat. That is, the CatDoctor
is assuming that the instance it is going to receive has all the methods that
one can expect from the type Cat.
The same is true for an AnimalDoctor. But the Animal's methods is a subset
of the Cat's methods. Therefore, an AnimalDoctor cannot be assigned to a
CatDoctor beacuse a CatDoctor may eventually call methods that are specific
to Cat instances.
At the end, the covariant and contravariant defintions expose the two visions one might have on
class hierarchies: the is-a relationship (the classical one) and has-a relationshoip (components).
Here is the full example:
#include <functional>
#include <iostream>
using namespace std;
class Animal{
virtual void make_noise() = 0;
};
class Cat: public Animal{
void make_noise();
};
class Horse: public Animal{
void make_noise();
void ride();
};
using AnimalBreeder = function<Animal*(void)>;
using AnimalDoctor = function<void(Animal*)>;
using CatBreeder = function<Cat*(void)>;
using HoserBreeder = function<Horse*(void)>;
using CatDoctor = function<void(Cat*)>;
using HorseDoctor = function<void(Horse*)>;
int main() {
CatBreeder cb = [](){return new Cat();};
AnimalBreeder ab = cb;
// HorseBreeder hb = ab; // Does not work
CatDoctor cd = [](Cat* cat){cout << "Hey, cat!";};
// AnimalDoctor ad = cd; // Does not work
AnimalDoctor ad = [](Animal*){cout << "Hey, animal!";};
HorseDoctor hd = ad;
return 0;
}
Invariant types
Two types are invariant if they do not respect the covariance principle neither the contravariant principle. It is easy to think about types in a hierarchy that are invariant. It is enough that their interfaces implement both the Consumer and Producer interface.
???+ Containers are usually invariant. Containers in C++ are invariant.
Bivariant types
Two types in a hierarchy are Bivariant if the covariant and contravariant principles both apply. Unfortunately, I could not find any practical example of this.