Sorry, I still don’t understand what type variance is
I enjoy static typing and use it at work and for personal projects. It makes my life easier, but sometimes the errors that my type checker produces confuse me. For example, I have heard of type covariance and contravariance, but I still can’t quite follow when and why should I care about that?
In this post we discuss a couple of realistic scenarios that lead us to the ideas of type variance.
Before we start…
… lets talk about the technical setup.
The code in this post is written in python3.11
, and we are using mypy==1.1.1
as a static type checker.
You can create a virtual environment by running the following commands:
> python3.11 -m venv variance-py311
> source variance-py311/bin/activate
> python -m pip install mypy==1.1.1
To type check a python file with a name filename.py
, you can run
> mypy --strict filename.py
The errors that we show in the examples below are mypy
errors, not runtime errors.
This type checker points to the potential problems that will almost certainly cause problems at run time of our program.
Our little helpers
To make our discussion easier, we define a couple of entities that we will use to illustrate the concepts.
First, we have a Human
class that represents all humans:
class Human:
pass
Second, we have two interfaces (or traits) that describe someone who likes science (LikesScience
),
and someone who is an expert in the nervous system (BrainExpert
).
class LikesScience:
def talk_about_science(self) -> None:
pass
class BrainExpert:
def explain_brain_activity(self) -> None:
pass
(Note that neither LikesScience
nor BrainExpert
are related to the Human
.
For example, a program can explain brain activity.)
Third, let’s define a subclass of Human
that represents scientists,
class Scientist(Human, LikesScience):
pass
and a subclass of Scientist
that represents neuroscientists:
class NeuroScientist(Scientist, BrainExpert):
pass
By definition, all NeuroScientist
s are Scientist
s, and all Scientist
s are Human
s.
We can confirm that with
assert isinstance(NeuroScientist(), Scientist)
assert isinstance(Scientist(), Human)
# or
assert issubclass(NeuroScientist, Scientist)
assert issubclass(Scientist, Human)
Types and subtypes
Let’s clarify what “type” and “subtype” mean.
In a programming language, a type is a description of
- a set of values, and
- a set of allowed operations on those values (ref).
The NeuroScientist
is a type that allows two operations on its values, talk_about_science
and explain_brain_activity
:
neuroscientist = NeuroScientist()
neuroscientist.talk_about_science()
neuroscientist.explain_brain_activity()
Imagine we have something that uses a Scientist
object, for example a function that interviews a scientist:
def interview(scientist: Scientist) -> None:
scientist.talk_about_science()
We constructed the NeuroScientist
class as a subclass of the Scientist
.
Is it true that we can interview a NeuroScientist
?
Certainly yes, because all NeuroScientist
s are Scientist
s, and in this example they can talk about science.
We can interview both a Scientist
and a NeuroScientist
(i.e., a scientist that happen to be a neuroscientist):
scientist = Scientist()
interview(scientist)
neuroscientist = NeuroScientist()
interview(neuroscientist)
This means that the NeuroScientist
type is a subtype of the Scientist
type.
Any NeuroScientist
can substitute a Scientist
, meaning that whatever can use a Scientist
will be just as happy to use a NeuroScientist
(see a more accurate discussion here)
Naturally, we can’t interview
a Human
- because not all Human
s are Scientist
s, and
Human
s in general cannot talk about science.
Just a Human
object does not allow the talk_about_science
operation, and this is not allowed:
human = Human()
interview(human) # Argument 1 to "interview" has incompatible type "Human"; expected "Scientist"
And therefore Human
is not a subtype of Scientist
(but, as you can imagine, Scientist
is a subtype of Human
).
Covariance: substituting more than a Scientist
Just as we can substitute a simple type Scientist
with its subtype NeuroScientist
,
can substitution work for more complex types that depend on either Scientist
or NeuroScientist
? And if yes, how does that work?
In other words, how does subtyping work for complex types? And what is a complex type after all?
Imagine we have conference where many scientists can meet and chat. It does not matter how many scientists attend a conference, all of them will talk about science:
def attend_conference(scientists: Sequence[Scientist]) -> None:
for scientist in scientists:
scientist.talk_about_science()
The scientists
is a Sequence
of Scientist
.
The fact that scientists
is a Sequence
means that the scientists
can be counted, we can confirm if a particular Scientist
is in the sequence, we can iterate over its elements, and a few other actions on the sequence are allowed.
We will talk about what Sequence
is later, but we can say that Sequence[Scientist]
is a complex type (as opposed to the simple Scientist
type) which is built on top of other types.
While a Sequence
of Scientist
can attend a conference, i.e., this is fine:
scientists = [Scientist(), Scientist(), Scientist()] # Sequence[Scientist]
attend_conference(scientists)
can a Sequence
of NeuroScientist
attend a conference?
Yes, because any NeuroScientist
is a Scientist
and can talk about science, so this is also fine:
neuro_scientists = [NeuroScientist(), NeuroScientist()] # Sequence[NeuroScientist]
attend_conference(neuro_scientists)
This shows that Sequence[NeuroScientist]
can substitute Sequence[Scientist]
, and therefore Sequence[NeuroScientist]
is a subtype of Sequence[Scientist]
.
The direction of the type relationship Scientist
→ NeuroScientist
is preserved when we wrap the types into a Sequence
: Sequence[Scientist]
→ Sequence[NeuroScientist]
.
The fact that the “type” to “subtype” direction remains the same when we talk about a Sequence
cannot be taken for granted, as we will see later.
It is a property of the Sequence
that it preserves this direction, and it is called covariance
: if we have two types, A
and B
, and B
is a subtype of A
, then Sequence[A]
is a subtype of Sequence[B]
.
There are other types that behave similarly (covariantly), for example Union
.
There are also types that are not covariant.
Let’s talk about those.
Contravariance: substituting functions
We created the interview
function above. mypy
allows to inspect the type of an object using reveal_type
:
reveal_type(interview)
# N: Revealed type is "def (scientist: Scientist)"
The interview
is a Callable
that accepts a single Scientist
input argument and returns nothing (None
).
The interview
, just like anything else, has its own type, and this type a complex type because it is built using other types.
We can explicitly define this type:
from typing import Callable
IT = Callable[[Scientist], None] # IT == Interview Type
and the type of interview
is IT
.
Let’s prepare a use case to illustrate the “substitute a function” scenario.
Imagine we have a NeuroScientist
friend, and we would like to organize a meeting with him.
For the meeting to be useful, we need to figure out some activity we can run with a
NeuroScientist
.
For example, let’s define an activity that is “ask a question about a brain night activity”:
def ask_brain_activity_question(neuroscientist: NeuroScientist) -> None:
neuroscientist.explain_brain_activity()
Similarly to how we inferred and explicitly defined the type of the interview
function,
we can do the same for ask_brain_activity_question
:
AQT = Callable[[NeuroScientist], None] # AQT = Ask Question Type
The type of ask_brain_activity_question
is AQT
.
Now we can create a meeting!
For a meeting, we will choose an activity of type AQT
, and run that activity with our NeuroScientist
friend:
def meeting(activity: AQT) -> None:
neuroscientist_friend = NeuroScientist()
activity(neuroscientist_friend)
We can run a meeting with the ask_brain_activity_question
as an activity, because meeting
expects an AQT
type of activity, and ask_brain_activity_question
is of the AQT
type:
meeting(ask_brain_activity_question)
But wait, since our neuroscientist_friend
is also a Scientist
, can we run a meeting with interview
as an activity?
meeting(interview)
Certainly yes, because interview
can run with any Scientist
.
What just happened is that we substituted something of type AQT
with something of type IT
.
This makes IT
a subtype of AQT
.
Apparently, contrary to the NeuroScientist
being a subtype of Scientist
, the Callable[[Scientist], None]
is a subtype of Callable[[NeuroScientist], None]
.
(Note, that if the meeting
specified activity: IT
as its input, we won’t be able to use something of type AQT
as an activity
.
This is because an AQT
activity can be an activity that only a NeuroScientist
can follow, while the meeting
stated that the activity
must be valid for any Scientist
.
This means that Callable
is not covariant in its arguments.)
This inversion of subtyping is a property of the Callable
type, and is called contravariance.
This property is not specific to our example: if we have two types, A
and B
, and B
is a subtype of A
, then Callable[[A], ...]
is a subtype of Callable[[B], ...]
.
To sum up, Callable
is contravariant in its arguments.
Sometimes substitution is not possible
There are covariant types, like Sequence
.
There are contravariant types, like Callable
in its arguments.
And there are types that neither co- nor contravariant.
Imagine we have a scientific outreach event.
We invite Scientist
s to the event so that they can inspire new people about what they do by talking about science.
We will consider our outreach event to be successful if someone suddenly realized that she likes science, and can talk about science herself!
That would make someone who was not a Scientist
before, a Scientist
after the event.
In this example, we have a group of Scientist
that can change during the event, as new Scientist
s might appear.
A MutableSequence
is a Sequence
that can change (the Sequence
does not allow operations that change it), and this is exactly what we want.
We define an outreach event as:
def outreach_event(scientists: MutableSequence[Scientist]) -> None:
for scientist in scientists:
scientist.talk_about_science()
new_scientist = Scientist()
scientists.append(new_scientist)
All scientists
talk, and in the end a new scientist appears and becomes a part of the scientists
group.
As we can expect, running such event with Scientist
s should be fine:
scientists = [Scientist(), Scientist(), Scientist()]
outreach_event(scientists)
Running such event with a MutableSequence[Human]
is wrong: a Human
cannot talk about science!
humans = [Human(), Human()]
outreach_event(humans)
# error: Argument 1 to "outreach_event" has incompatible type "MutableSequence[Human]";
# expected "MutableSequence[Scientist]" [arg-type]
We can conclude that MutableSequence
is not contravariant, as we cannot substitute MutableSequence[Scientist]
with MutableSequence[Human]
.
Interestingly, running this event with MutableSequence[NeuroScientist]
is also a bad idea.
As we said, someone becomes a Scientist
during the event, but attending the event is not enough to acquire a brain expert certification to become a NeuroScientist
.
So we may end up with a Scientist
being added to the existing group of NeuroScientist
s - and all of a sudden that group stops being a group of NeuroScientist
s, as not all of its members are brain experts.
This is therefore wrong:
neuro_scientists: MutableSequence[NeuroScientist] = [NeuroScientist(), NeuroScientist()]
outreach_event(neuro_scientists)
# error: Argument 1 to "outreach_event" has incompatible type "MutableSequence[NeuroScientist]";
# expected "MutableSequence[Scientist]" [arg-type]
We can conclude that MutableSequence
is not covariant, as we cannot substitute MutableSequence[Scientist]
with MutableSequence[NeuroScientist]
.
MutableSequence
is an example of a type that is neither covariant nor contravariant.
Such types are called invariant.
How generic can this be?
Sequence
, Callable
, MutableSequence
, and many more…
Those types only make sense if they are of something.
For example, a sequence of scientists - Sequence[Scientist]
, or a mutable sequence of humans - MutableSequence[Human]
.
Otherwise, what is a Sequence
, a sequence of what?
We can’t work with a Sequence
of unspecified things.
These complex types that require a simple type (or multiple simple types) to be bound to are called generic types.
We can create our own generic types using the Generic
as a base:
from typing import Generic, TypeVar
T = TypeVar("T")
class Journal(Generic[T]):
pass
In this example, Journal
is a generic type with one parameter (just like Sequence
).
We must somehow specify how many parameters a generic type has, and this is how we do that.
To create the parameters we use the TypeVar
.
Creating a object of a generic type is slightly different to how we create objects of non-generic types.
For example, this does not make sense because we don’t specify a Journal
of what we just created:
journal = Journal()
# error: Need type annotation for "journal" [var-annotated]
On the other hand, explicitly specifying the value for the parameter type makes it work:
journal = Journal[Scientist]()
Now we know that a journal
is a Journal
that belongs to a Scientist
.
We said that complex types, such as Journal
, can be covariant, contravariant, or invariant, and that it is a property of the type.
How can we control this property for the type we just created, Journal
?
Being co-, contra-, or invariant can be controlled via the type parameters.
This makes more sense if we consider generic types with multiple parameters, such as Mapping
.
Mapping
is a collection of (key, value)
pairs, and we need two parameters that describe this generic type.
In general, the generic class can behave differently with respect to its parameters.
Mapping
is invariant on key
(only specific type is allowed as keys) and covariant on value
(it is allowed to store subtypes as values).
How can a type parameter control the variance?
When defining a type parameter, TypeVar
accepts covariant=True
or covariant=False
(default), and contravariant=True
or contravariant=False
(default) as arguments.
For example, T = TypeVar("T")
creates an invariant type argument, because both covariant
and contravariant
are False
by default.
To create a covariant T_co
or a contravariant T_contra
type arguments, we do:
T_co = TypeVar("T_co", covariant=True)
T_contra = TypeVar("T_contra", contravariant=True)
The Journal
type defined above is invariant, because its type argument T
was defined as invariant.
To create a covariant version of a Journal
, JournalCo
, and a contravariant version JournalContra
, we write:
class JournalCo(Generic[T_co]):
pass
class JournalContra(Generic[T_contra]):
pass
The Sequence
type that we used before is actually defined something like class Sequence(Generic[T_co]): ...
(covariant because of T_co
), and MutableSequence
is defined something like class MutableSequence(Generic[T]): ...
(invariant because of T
).
As we can imagine, all variance rules that we discussed before should apply to the Journal
s.
We cannot substitute anything for an invariant Journal
:
def read_invariant(journal: Journal[Scientist]) -> None:
pass
human_journal = Journal[Human]()
scientist_journal = Journal[Scientist]()
neuroscientist_journal = Journal[NeuroScientist]()
read_invariant(human_journal) # error: Argument 1 to "read_invariant" has incompatible type "Journal[Human]"; expected "Journal[Scientist]" [arg-type]
read_invariant(scientist_journal) # OK
read_invariant(neuroscientist_journal) # error: Argument 1 to "read_invariant" has incompatible type "Journal[NeuroScientist]"; expected "Journal[Scientist]" [arg-type]
We can substitute a subtype for a covariant JournalCo
:
def read_covariant(journal: JournalCo[Scientist]) -> None:
pass
human_journal_co = JournalCo[Human]()
scientist_journal_co = JournalCo[Scientist]()
neuroscientist_journal_co = JournalCo[NeuroScientist]()
read_covariant(human_journal_co) # error: Argument 1 to "read_covariant" has incompatible type "JournalCo[Human]"; expected "JournalCo[Scientist]" [arg-type]
read_covariant(scientist_journal_co) # OK
read_covariant(neuroscientist_journal_co) # OK
And we can substitute a supertype for contravariant JournalContra
:
def read_contravariant(journal: JournalContra[Scientist]) -> None:
pass
human_journal_contra = JournalContra[Human]()
scientist_journal_contra = JournalContra[Scientist]()
neuroscientist_journal_contra = JournalContra[NeuroScientist]()
read_contravariant(human_journal_contra) # OK
read_contravariant(scientist_journal_contra) # OK
read_contravariant(neuroscientist_journal_contra) # error: Argument 1 to "read_contravariant" has incompatible type "JournalContra[NeuroScientist]"; expected "JournalContra[Scientist]" [arg-type]
Summary
Types and subtypes are related behaviourally: it is type safe to substitute a type with a subtype, and expect that a program will behave correctly.
Complex (generic) types are built on top of simple types, and can behave differently than the types they were built with. This is called variance, and it refers to how subtyping between more complex types relates to subtyping between their components (ref). Covariant means that the subtyping relation of the simple types are preserved for the complex types. Contravariant means the subtyping relation of the simple types is reversed for the complex types (ref).
Standard and custom generic classes are defined using type variables as parameters. Covariance or contravariance is not a property of a type variable, but a property of a generic class defined using this variable (ref).