Covariance, Contravariance, and Invariance — The Ultimate Python Guide
[s02e03] CSI: Python Type System
This blog post is about covariance, contravariance, and invariance of Python types. I define these concepts and explain them in detail. Every step is accompanied by a fairly straightforward code snippet. I aim to show that the knowledge of these concepts helps to write more reliable code and it is beneficial to all Python programmers.
This is the third part of a blog post series about contravariance and issues related to it. This part is self-contained, though, and can be read separately. The first two parts are devoted to understanding (s02e01) and fixing (s02e02) a specific contravariance-related bug.
If you are new to Python type annotations, I suggest reading my two introductory blog posts first: s01e01 and s01e02.
There are some formalisms in this post, yet they are quite straightforward. I use <:
symbol, which reads “is a subtype of” (i.e. subtype is always at the pointy end 🗡 😉). If you are not sure what subtyping is or how it differs from subclassing, just read this and this part of s01e01 blog post.
All code is compatible with Python 3.6+. I use mypy 0.641 type checker.
Covariance
Tuple
Let’s say we have a tuple of Dog
s and a tuple of Animal
s. Their types are Tuple[Dog, ...]
and Tuple[Animal, ...]
, respectively (for a reminder about Tuple
type, see here). Every object with the type of Tuple[Dog]
can be safely assigned to a variable of the type Tuple[Animal]
, but not the other way around:
Why the error is reported? It's because animals
tuple could contain other animals than dogs. (And in our code in fact it does: an_animal
.) Therefore, the type of dogs
, which is Tuple[Dog, ...]
, could be compromised if we assigned an object of Tuple[Animal, ...]
type to it. (And in our code in fact it is assigned.)
On the other hand, mypy is happy with animals = dogs
assignment. It’s because every element of dogs
is a Dog
and, due to tuple’s immutability, cannot be anything else. Therefore, it can be used where an object of the type Animal
is expected, as Dog
is a subtype of Animal
. Thus Tuple[Dog, ...] <: Tuple[Animal, ...]
.
More generally:
Tuple[SubType, ...] <: Tuple[SuperType, ...]
,
forSubType <: SuperType
.
Similarly, in the case of multiple-type tuples:
Tuple[SubType1, SubType2, etc.] <: Tuple[SuperType1, SuperType2, etc.]
,
forSubType1 <: SuperType1
,SubType2 <: SuperType2
(etc.).
Definition of Covariance
The property of Tuple
type we have just discovered is called a covariance. Or, more precisely: Tuple
is covariant in all its arguments. The formal definition of the covariance is as follows (I slightly modified the definition from mypy docs):
Generic type
GenType[T, ...]
is covariant in type variableT
ifGenType[SubType, ...] <: GenType[SuperType, ...]
,
forSubType <: SuperType
.
A generic type is, basically, a type that takes other types as its parameters, in square brackets, like Tuple
, List
,Union
(see mypy docs). In my first blog post about typing, I called it a “complex type”.
In Python, most immutable containers are covariant. Tuples and frozensets
(their type is FrozenSet
) are the most significant ones. But there are other covariant types as well.
Union
Union
type is covariant in all its arguments:
Union[SubType1, SubType2, etc.] <: Union[SuperType1, SuperType2, etc.]
,
forSubType1 <: SuperType1
,SubType2 <: SuperType2
(etc.).
The covariance of Union
basically works the same way as the covariance of Tuple
. Let’s say we have two pairs of types: Dog <: Animal
and Meat <: Food
. It’s safe to use an object of the type Union[Dog, Meat]
where an object of the type Union[Animal, Food]
is expected, but not the other way around. So, it’s okay to assign an object of the type Union[Dog, Meat]
to a variable of the type Union[Animal, Food]
, but not vice versa.
Return Type of Callable
The case of Callable
is a little bit more interesting. We can look at Callable
type from two points of view: types of its arguments and the type of its return value.
As for the type of return value, let’s look at the following functions. Both are getting dogs and animals from, say, external services.
Now, it’s safe to use get_dog
(returning a Dog
) wherever get_animal
(returning an Animal
) is expected.
some_animal: Animal = get_animal()
Type of some_animal
is Animal
. get_animal
can safely be switched with get_dog
, then. It's because it will return a Dog
which can be safely used wherever Animal
is used because Dog <: Animal
. So Callable[[], Dog] <: Callable[[], Animal]
, or generally:
Callable[[], SubType] <: Callable[[], SuperType]
,
forSubType <: SuperType
.
So Callable
is covariant in the return type.
In more general terms, anything that provides something — producer, factory, constructor, writer, etc. — is covariant in the type of the objects it provides. It needs to be read-only with regard to the provided objects, though.
Contravariance
Definition of Contravariance
Contrary term to “covariance” is “contravariance”. It’s defined as follows:
Generic type
GenType[T, ...]
is contravariant in type variableT
ifGenType[SuperType, ...] <: GenType[SubType, ...]
,
forSubType <: SuperType
.
The definition is almost the same as of “covariance”, but with <:
relation switched. Let’s get back to Callable
.
Arguments of Callable
The other way of looking at Callable
is from the types of its arguments point of view. It turns out — and we will see why in a second — that in the case of function’s arguments, subtyping works the other way around compared to plain objects:
Callable[[SuperType], None] <: Callable[[SubType], None]
,
forSubType <: SuperType
.
So, Callable
is contravariant in the argument types. Take note that for Callable
contravariance works for an arbitrary number of arguments, as long as they are in subtyping relationships in a pairwise fashion:
Callable[[SuperType1, SuperType2, etc.], None] <: Callable[[SubType1, SubType2, etc.], None]
,
forSubType1 <: SuperType1
,SubType2 <: SuperType2
(etc.).
Let’s find out why Callable
is not covariant but contravariant in the argument types, then. (The following part is the essence of s02e01 post. Look there for more examples and explanations.)
Take a look at the following code:
Can we assign animal_run = dog_run
? That is: can we safely use dog_run
wherever animal_run
is expected? If we could, it’d mean that Callable[[Dog], None]
is a subtype of Callable[[Animal], None]
. It turns out we cannot safely make the assignment. If we could, it would be possible to use dog_run
on an incompatible object, say, a Kangaroo
.
Why mypy reports this error? So, make_animal_run
gets two arguments: Bob, a Kangaroo
(and it indeed is type-safe to pass him here, since Kangaroo <: Animal
) and a dog_run
— a Callable[[Animal], None]
. Now, inside make_animal_run
function dog_run
function is to be called on kangaroo Bob. It’s incorrect since dog_run
can only be called on a Dog
and its subtypes. Therefore, we cannot safely pass dog_run
to make_animal_run
. For this reason, Callable[[Dog], None]
is not a subtype of Callable[[Animal], None]
. And this, in turn, leads to the conclusion: Callable
is not covariant in the argument types.
What about the contravariance? Can we assign dog_run = animal_run
? That is: can we safely use animal_run
wherever dog_run
is expected?
animal_run
accepts an Animal
, so it needs to work with a Dog
, since Dog <: Animal
. Thus, it’s safe to use animal_run
when dog_run
is expected. Therefore, Callable[[Animal], None] <: Callable[[Dog], None]
. As a consequence, Callable
is contravariant in the argument types.
So now you understand what “function is covariant in the return type, but contravariant in the argument types” means. You nerd! 🤓
In more general terms, anything that takes something— consumer, sink, reader, listener, etc. — is contravariant in the type of the objects it takes. It needs to be write-only with regard to the taken objects, though.
Invariance
Definition of Invariance
An invariant type is neither covariant nor contravariant. More formally:
Generic type
GenType[T, ...]
is invariant in type variableT
ifGenType
is neither covariant in type variableT
nor contravariant in type variableT
.
List
Yes, there’s no mistake, List
is not like Tuple
, which is covariant. List
is neither covariant nor contravariant. I will use this code to show it:
All Animal
s, including Dog
s and Kangaroo
s, can eat. Only Dog
s can bark, and only Kangaroo
s can leap.
Let’s get the obvious out of the way — List
type is not contravariant. It’s not because you cannot safely assign animals
to dogs
, as animals
may contain non-Dog
s. It’s exactly the same as in Tuple
’s case. Mypy agrees:
Now, what about the covariance? If List
was a covariant type, we could safely use a variable of the type List[Dog]
instead of a variable of the type List[Animal]
, yet we cannot:
Why? Shouldn’t it be the same as in Tuple
’s case as well?
Let’s step back a little and think what the difference between covariant Tuple
type and List
type is. Both are Sized
, both are Iterable
s and both are Container
s. Yet, there is an essential difference between lists and tuples — mutability. Python tuples are immutable — you cannot add or remove their elements. Lists are mutable — you can add and remove their elements. It turns out that List
’s mutability is something that determines List
type not being covariant. If we used List[Dog]
wherever List[Animal]
is expected, we could append an incompatible object:
So dogs
list of the type List[Dog]
got an object that cannot bark — a Kangaroo
. That’s why you cannot safely use object of List[Dog]
type whenever there is an object of the type List[Animal]
expected. Therefore List[Dog]
is not a subtype of List[Animal]
. Remember: the type is defined not only by a set of objects but also by functions/methods that can be used with these objects.
This is the same exploit that we discovered when discussing Callable
. But List
, contrary to Callable
, is not contravariant either, as we've seen. It is invariant then:
We neither have that
List[SubType] <: List[SuperType]
nor thatList[SuperType] <: List[SubType]
, forSubType <: SuperType
.
Other invariant types are Set
, Dict
, and many more mutable containers. I think it is pretty obvious now and no further examples are needed. This feature of immutable containers is clearly advantageous over mutable ones. But that’s another story for another time…
In more general terms, any structure that supports both read and write operations on some set of objects — mutable container, queue, stack, heap, router, etc. — is invariant in the type of the objects it operates on.
Bonus: Liskov Substitution Principle
In this blog post series, I frequently said something like it’s type-safe to use an object of the SubType
type where an object of the SuperType
type is expected. This assumption is based on Liskov Substitution Principle — the principle formulated by Barbara Liskov, an American computer scientist. Wikipedia states it as follows:
[…] if S is a subtype of T, then objects of type T may be replaced with objects of type S (i.e. an object of type T may be substituted with any object of a subtype S) without altering any of the desirable properties of the program (correctness, task performed, etc.). [source]
This is a pretty strong requirement — when I replace any object of SuperType
with an object of SubType
(where SubType <: SuperType
) my program still needs to function correctly. It means not only that it cannot raise any new exceptions, but also it still needs to work according to its (original) specification. This even means that we cannot override methods in subclasses if it changes the original behaviour; we can only add new methods. Python type system is not so rigid and mypy won’t complain when we override a method and change its behaviour (both methods— overridden and overriding — still need to have the same argument types, though!). Yet, it’s good to know how to write even safer code 👷
If you want to know more on this topic I suggest reading Liskov and type safety blog post by Brent Roose.
Sources
When researching stuff and writing this blog post series, I used multiple sources. You might find them helpful if you want to dig further ⛏️
PEPs & Docs
— PEP 483: The Theory of Type Hints
— Generics section of mypy docs
Blog Posts & Talk Slides
— What are covariance and contravariance? blog post by Stephan Boyer
— Wizards and warriors blog post series by Eric Lippert
— Liskov and type safety blog post by Brent Roose
— Covariance and contravariance. Say what?! slides by Alin Pandichi
Wikipedia Articles
— Covariance and contravariance (computer science) Wikipedia article
— Liskov substitution principle Wikipedia article
GitHub Issues & Stack Overflow Questions
— Why is “incompatible with supertype” an error? mypy GitHub issue
— Allow subclassing without supertyping mypy GitHub issue
— Mypy reports an incompatible supertype error with overridden method Stack Overflow discussion
— Picture by Andrey Tyukin illustrating covariance and contravariance, published on Stack Overflow
If you enjoyed this post, please hit the clap button below 👏👏👏