TIL: invariant, covariant, and contravariant type variables #
I avoided learning this for so long.
While working on castfit
, I ended up going down a rabbit hole learning about what makes something a subtype of something else. I vaguely knew the terms "invariant", "covariant", and "contravariant", but have very carefully avoided ever learning what they meant.
If you look at the Wikipedia entry on Covariance and contravariance (computer science) you see explanations like:
Suppose
A
andB
are types, andI<U>
denotes application of a type constructorI
with type argumentU
. Within the type system of a programming language, a typing rule for a type constructorI
is:
- covariant if it preserves the ordering of types (≤), which orders types from more specific to more generic: If
A ≤ B
, thenI<A> ≤ I<B>
;- contravariant if it reverses this ordering: If
A ≤ B
, thenI<B> ≤ I<A>
;- bivariant if both of these apply (i.e., if
A ≤ B
, thenI<A> ≡ I<B>
);[1]- variant if covariant, contravariant or bivariant;
- invariant or nonvariant if not variant.
Uh, super helpful. The introductory description is a lot better than it used to be, but talking it over with ChatGPT was actually the best way for me to understand what these terms mean.
Simpler Definitions #
Suppose we have a type hierarchy like:
Labrador → Dog → Animal
Siamese → Cat → Animal
The arrow indicates an "is a" relationship moving from more specific (narrow) to more general (broad): a Labrador is a type of Dog; a Dog is a type of Animal, etc.
The question of co/contra/invariance comes up when you want to know: Can I substitute one type for another?
It turns out that this depends on many things, but the situations are classified as:
- Invariant: It must be this exact type. No broader or narrower substitutions allowed.
- Covariant: The same or broader (more general) type is acceptable.
- Contravariant: The same or narrower (more specific) type is acceptable.
- Bivariant (haven't seen this in Python): The same, broader, or narrower type is acceptable.
This mostly comes into play when you start looking at containers of objects like list
, set
, tuple
, dict
, etc. Also Python has several exceptions to its own rules that are not immediately obvious.
General Python Rules #
- The contents of mutable containers are invariant. For example, if a function takes
list[Animal]
, you cannot passlist[Dog]
because that function might add aCat
(which is anAnimal
) there by violating the type safety oflist[Dog]
that was passed in.
An exception to this rule in Python is when "implicit conversion" occurs. A function that takes list[int]
is ok to take list[bool]
because there is an implicit conversion that happens. It seems that the numeric tower of bool → int → float → complex
all happens implicitly. There are a few other implicit conversions that I'm still learning about.
- The contents of immutable containers (or read-only situations) are covariant. A function that takes
Sequence[Animal]
is ok to takeSequence[Dog]
becauseSequence
is read-only (andAnimal
is broader thanDog
).
Not really an exception, but fun fact: fixed-length tuple
are covariant, while variable-length tuples are invariant.
PEP 483 has a nice example of contravariant types: Callable
. While it is covariant in its return type (Callable[[], Dog]
is a subtype of Callable[[], Animal]
), it is contravariant in its arguments (Callable[[Animal], None]
is a subtype of Callable[[Dog], None]
).
This leads to a good rule of thumb:
The example with
Callable
shows how to make more precise type annotations for functions: choose the most general type for every argument, and the most specific type for the return value.
It looks like PEP 695 made it into Python 3.12, so maybe reasoning about this will get easier in the future especially because it automatically infers the variance of the type variables.