Variance
Variance describes how subtyping works with generics. Let's take a look at a few examples to set the stage.
Example 1. Iterables
Suppose that we have this class hierarchy1:
class Animal:
def __init__(self, name: str) -> None:
self._name = name
def name(self) -> str:
return self._name
class Dog(Animal):
def bark(self) -> None:
print("Woof!")
And we have this function:
from collections.abc import Iterable
def greet_animals(animals: Iterable[Animal]) -> None:
for animal in animals:
print("Hi, {0}!".format(animal.name()))
Should this call be allowed?
The answer is yes! All we can do with an Iterable
is iterate over it.
Every element of the iterable will be a Dog
, which is a subtype of Animal
. Our code
can't assume anything else about the elements, so it should work.
So as you can see, Dog
is a subtype of Animal
, therefore Iterable[Dog]
is a subtype of Iterable[Animal]
.
But that's not true for all generic types.
Example 2. Lists
Let's take a slightly different function:
def greet_animals(animals: list[Animal]) -> None:
for animal in animals:
print("Hi, {0}!".format(animal.name()))
Should this call be allowed?
If we run pyright
on this code, this is what we see:
Argument of type
"list[Dog]"
cannot be assigned to parameter"animals"
of type"list[Animal]"
in function"greet_animals"
How so? The call will work fine at runtime, so why not allow it?
Well, for all the type checker knows, greet_animals
could do this:
class Cat(Animal):
def meow(self) -> None:
print("Meow!")
def greet_animals(animals: list[Animal]) -> None:
animals.append(Cat("dennis"))
animals[0] = Animal("emily")
for animal in animals:
print("Hi, {0}!".format(animal.name()))
The contract of list[Animal]
is that you can push any Animal
to it.
list[Dog]
doesn't satisfy this contract: there are Animal
s you cannot push
into a list[Dog]
, for example a Cat
or a plain Animal
object.
This call is also not allowed:
def greet_dogs(dogs: list[Dog]) -> None:
for dog in dogs:
print("Hi, {0}!".format(dog.name()))
dog.bark()
animals: list[Animal] = [Animal("alice"), Cat("bob"), Dog("charlie")]
greet_dogs(animals)
A list[Animal]
can already contain animals that are not
Dog
s, so the assumptions of the function (namely, the fact that you can call
bark()
on each element in the list) will be violated.
In other words, list[Dog]
is not a subtype of list[Animal]
, and list[Animal]
is not a subtype of list[Dog]
either.
Example 3. Functions
Consider this function:
from collections.abc import Callable
def create_animals(factory: Callable[[str], Animal]) -> tuple[Animal, Animal]:
return factory("alice"), factory("bob")
Which of these functions would work as the factory
?
class BetterString(str):
...
def to_awesome_case(self) -> str:
import random
return "".join(random.choice([char.lower(), char.upper()]) for char in self)
def make_animal(name: str) -> Animal: ...
def make_cat(name: str) -> Cat: ...
def make_cat_or_dog(name: str) -> Cat | Dog: ...
def make_cat2(name_or_id: str | int) -> Cat: ...
def make_cat3(whatever: object) -> Cat: ...
def make_animal2(name: BetterString) -> Animal: ...
def make_animal_of_plant(name: str) -> Animal | Plant: ...
-
def make_animal(name: str) -> Animal: ...
This function has exactly the type we need -
Callable[[str], Animal]
. So yes, it will work -
def make_cat(name: str) -> Cat: ...
This function returns a
Cat
instead of just any animal. But that's fine: we need a function that returns anAnimal
, and whatevermake_cat
returns is anAnimal
.You can think of it in another way: you could make a trivial function that transforms this into
(name: str) -> Animal
: -
def make_cat_or_dog(name: str) -> Cat | Dog: ...
Same for this function: whatever it returns, it is an
Animal
. -
def make_cat2(name_or_id: str | int) -> Cat: ...
This one is more tricky, but it will still fit: we need a function that accepts a string as an argument, and this function satisfies this requirement.
In other words,
make_cat2
accepts any subtype ofstr | int
as an argument, andstr
is a subtype ofstr | int
. -
def make_cat3(whatever: object) -> Cat: ...
This is also fine.
make_cat3
will work with any object at all, including a string. -
def make_animal2(name: BetterString) -> Animal: ...
This one will not fit. We need our
factory
to accept any string, butmake_animal2
only acceptsBetterString
objects and can, for example, call theto_awesome_case()
method on the name. -
def make_animal_of_plant(name: str) -> Animal | Plant: ...
This will not work either. The function should always return an
Animal
.
As you can see, there are two rules for functions:
- If
Child
is a subtype ofParent
, thenCallable[[X], Child]
is a subtype ofCallable[[X], Parent]
- If
Child
is a subtype ofParent
, thenCallable[[Parent], X]
is a subtype ofCallable[[Child], X]
Definition
Suppose that we have a generic type F[T]
, and two ordinary types: Child
and Parent
, where Child
is a subtype of Parent
.
The variance of F
describes how F[Child]
is related to F[Parent]
.
There are three kinds of variance:
-
Covariance:
F[Child]
is a subtype ofF[Parent]
- example:
tuple[Dog, ...]
is a subtype oftuple[Animal, ...]
- example:
Callable[[str], Dog]
is a subtype ofCallable[[str], Animal]
- example:
-
Contravariance:
F[Parent]
is a subtype ofF[Child]
- example:
Callable[[Animal], None]
is a subtype ofCallable[[Cat], None]
- example:
-
Invariance:
F[Child]
is not related toF[Parent]
- example:
list[Dog]
is not related tolist[Animal]
- example:
When describing a type, replace the "ance" suffix with "ant". For example,
list
is invariant, while tuple
is covariant.
More than one type variable
Some generic types take in more than one type variable. For example, Callable
can take
any number of variables for parameters, and then another one for the result type.
In that case, a generic type can have different variance in different type variables.
For example:
Callable
is contravariant in the parameter types but covariant in the return typeMapping
is invariant in the key type but covariant in the return type
-
I know, I hate nonsense examples of inheritance as well. I hope that you forgive me. ↩