I’ve always had a hard time explaining variance of generic types while working with type annotations in Python. This is an attempt to distill the things I’ve picked up on type variance while going through PEP-483.
A pinch of type theory
A generic type is a class or interface that is parameterized over types. Variance refers to how subtyping between the generic types relates to subtyping between their parameters' types.
Throughout this text, the notation
T2 <: T1
denotesT2
is a subtype ofT1
. A subtype always lives in the pointy end.
If T2 <: T1
, then a generic type constructor GenType
will be:
- Covariant, if
GenType[T2] <: GenType[T1]
for all suchT1
andT2
. - Contravariant, if
GenType[T1] <: GenType[T2]
for all suchT1
andT2
. - Invariant, if neither of the above is true.
To better understand this definition, let’s make an analogy with ordinary functions. Assume that we have:
# src.py
from __future__ import annotations
def cov(x: float) -> float:
return 2 * x
def contra(x: float) -> float:
return -x
def inv(x: float) -> float:
return x * x
If x1 < x2
, then always cov(x1) < cov(x2)
, and contra(x2) < contra(x1)
, while nothing
could be said about inv
. Replacing <
with <:
, and functions with generic type
constructors, we get examples of covariant, contravariant, and invariant
behavior.
A few practical examples
Immutable generic types are usually type covariant
For example:
Union
behaves covariantly in all its arguments. That means: ifT2 <: T1
, thenUnion[T2] <: Union[T1]
for all suchT1
andT2
.FrozenSet[T]
is also covariant. Let’s considerint
andfloat
in place ofT
. First,int <: float
. Second, a set of values ofFrozenSet[int]
is clearly a subset of values ofFrozenSet[float]
. Therefore,FrozenSet[int] <: FrozenSet[float]
.
Mutable generic types are usually type invariant
For example:
list[T]
is invariant. Although a set of values oflist[int]
is a subset of values oflist[float]
, only anint
could be appended to alist[int]
. Therefore,list[int]
is not a subtype oflist[float]
.
The callable generic type is covariant in return type but contravariant in the arguments
Callable[[], int] <: Callable[[], float]
.- If
Manager <: Employee
thenCallable[[], Manager] <: Callable[[], Employee]
.
However, for two callable types that differ only in the type of one argument, the subtype relationship for the callable types goes in the opposite direction as for the argument types. Examples:
Callable[[float], None] <: Callable[[int], None]
, whereint <: float
.Callable[[Employee], None] <: Callable[[Manager], None]
, whereManager <: Employee
.
I found this odd at first. However, this actually makes sense. If a function can calculate
the salary for a Manager
, it should also be able to calculate the salary of an Employee
.
Examples
Covariance
# src.py
from __future__ import annotations
# In <Python 3.9, import this from the 'typing' module.
from collections.abc import Sequence
class Animal:
pass
class Dog(Animal):
pass
def action(animals: Sequence[Animal]) -> None:
pass
if __name__ == "__main__":
action((Animal(),)) # ok
action((Dog(),)) # ok
Here, Dog <: Animal
and notice how Mypy doesn’t raise an error when a tuple of Dog
instance is passed into the action
function that expects a sequence of Animal
instances.
However, if you make change the action
function as follows:
...
def action(animals: Sequence[Dog]) -> None:
pass
if __name__ == "__main__":
action((Animal(),)) # not ok
action((Dog(),)) # ok
Mypy will complain about this snippet since now, action
expects a sequence of Dog
instance or a subtype of it. A sequence of Animal
is not a subtype of a sequence of Dog
.
Hence, the error.
Contravariance
The Callable
generic type is covariant in return type. Here’s how you can test it:
from __future__ import annotations
# In <Python 3.9, import this from the 'typing' module.
from collections.abc import Callable
def factory(func: Callable[..., float]) -> Callable[..., float]:
return func
def foo() -> int:
return 42
def bar() -> float:
return 42
if __name__ == "__main__":
factory(foo) # ok
factory(bar) # ok
Here, int <: float
and the in the return type, you can see
Callable[..., int] <: Callable[float]
as Mypy is satisfied when either foo
or bar
is
passed into the factory
callable.
On the other hand, the Callable
generic type is contravariant in the argument type.
Here’s how you can test it:
from __future__ import annotations
# In <Python 3.9, import this from the 'typing' module.
from collections.abc import Callable
def factory(func: Callable[[float], None]) -> Callable[[float], None]:
return func
def foo(number: int) -> None:
pass
def bar(number: float) -> None:
pass
if __name__ == "__main__":
factory(foo) # not ok
factory(bar) # ok
Here, Mypy will complain in the case of factory(foo)
as the factory function expects
Callable[[float]], None]
or its subtype. However, in the above case,
Callable[[float]], None] <: Callable[[int], None]
but not the other way around. That
causes the error.
Invariance
In general, types defined with the TypeVar
construct are invariant. You can mark them as
covariant or contravariant as well. However:
Remember that variance is a property of the generic types; not their parameter types.
Here’s how you can mark types as covariant, contravariant, or invariant:
from __future__ import annotations
from typing import Generic, TypeVar
T = TypeVar("T")
T_co = TypeVar("T_co", covariant=True)
T_contra = TypeVar("T_contra", contravariant=True)
class HolderInv(Generic[T]):
def __init__(self, *args: T) -> None:
self.args = args
class HolderCov(Generic[T_co]):
def __init__(self, *args: T_co) -> None:
self.args = args
class HolderContra(Generic[T_contra]):
def __init__(self, *args: T_contra) -> None:
self.args = args
def process_holder_inv(holder: HolderInv[float]) -> None:
pass
def process_holder_cov(holder: HolderCov[float]) -> None:
pass
def process_holder_contra(holder: HolderContra[float]) -> None:
pass
if __name__ == "__main__":
holder_inv = HolderInv(1.0) # ok
holder_cov = HolderCov(1, 2) # ok
holder_contra = HolderContra(
1, 2
) # raises error because T is contravariant
process_holder_inv(holder_inv)
process_holder_cov(holder_cov)
process_holder_contra(holder_contra)
Recent posts
- SSH saga
- Injecting Pytest fixtures without cluttering test signatures
- Explicit method overriding with @typing.override
- Quicker startup with module-level __getattr__
- Docker mount revisited
- Topological sort
- Writing a circuit breaker in Go
- Discovering direnv
- Notes on building event-driven systems
- Bash namerefs for dynamic variable referencing