We briefly encountered the concept of covariance in the previous article. Now lets take a deeper look.
Variance refers to places where one data type can be substituted for another.
- Covariance means that a data type can be substituted with a more specific type
- Contravariance means that a data type can be substituted with a more general type
- Invariance means that a data type cannot be substituted either way
The most common case is inheritance. Here is the example from the previous article
class Person:
def __init__(self, name: str) -> None:
self.name = name
def greet(self) -> str:
return f'Hello {self.name}'
class Employee(Person):
def __init__(self, name: str, employee_id: int) -> None:
super().__init__(name)
self.employee_id = employee_id
def login(self) -> None:
print(f'logging in with it {self.employee_id}')
If we declare a function that expects a type Person
, we can safely pass in an object of type Employee
def hello(p: Person):
print(p.greet())
aparna: Employee = Employee('Aparna', 3)
hello(aparna) # ✅ OK
The formal computer science term for this behaviour is covariance.
In Python, normal classes are covariant, meaning we can safely pass in a child class when the parent class is expected.
Container Types
Most people find parent-child covariance quite intuitive. But things get complex when generic types are involved. We already know that Employee
is covariant with Person
. What about list[Employee]
and list[Person]
? Are they covariant?
Consider the code below, is it correct?
def add_person(p: list[Person]) -> None:
p.append(Person("Anjali"))
people: list[Employee] = [Employee('Aparna', 3)]
add_person(people) # ❌ Wrong
for p in people:
p.login()
Seeing this example, it should be clear that the code is not correct. people
is a list of Employee
. When we pass the list to add_person
, the function is adding a Person
object into this list which is incorrect. When we return from the function, in the loop we do p.login()
and this will fail since the Person
object that was added does not have that method.
Running mypy
on the code above gives this error
test.py:23: error: Argument 1 to "add_person" has incompatible type "List[Employee]"; expected "List[Person]" [arg-type]
From this we can conclude that list[Employee]
is not covariant with list[People]
. We cannot substitute list[Employee]
in a place where list[People]
is expected. A similar examination will show that the opposite is also not possible. We cannot substitute list[People]
in a place where list[Employee]
is expected.
This means that the generic type list[T]
is invariant.
On the other hand, consider a function that only reads values from the container, without adding anything to it. In the example below, we can use the Sequence
type instead of list
. A Sequence
only allows iteration of the data.
from typing import Sequence
def print_all(people: Sequence[Person]) -> None:
for p in people:
print(p.greet())
people: list[Employee] = [Employee('Aparna', 3)]
print_all(people) # ✅ OK
Here, it is safe to pass an object of type list[Employee]
to the function print_all
(A list
is also a Sequence
because is can be iterated).
So the data type Sequence[T]
is covariant.
Generally speaking, container types that only read values from the container (eg: Sequence
) are covariant. While those that contain methods for both reading and adding new items (eg: list
) are invariant.
Function Types
In Python, functions are first class objects. We can pass functions as parameters or return functions from other functions. There is a need to make a function signature as a data type. In Python. it is represented by the Callable
generic type. Here is an example to make it clearer
from typing import Callable
def process_people(
employees: list[Employee],
fn: Callable[[Employee], str]
) -> list[str]:
return [fn(p) for p in employees]
Look at the data type of the fn
parameter: Callable[[Employee], str]
For this parameter we should pass a function that takes a single input parameter of type Employee
and returns an output of type str
.
def get_employee_display(p: Employee):
return f"{p.name} [{p.employee_id}]"
people: list[Employee] = [Employee("Anjali", 1), Employee("Aparna", 2)]
displays = process_people(people, get_employee_display) # ✅ OK
print(displays) # ['Anjali [1]', 'Aparna [2]']
Now consider this function that takes Person
as input instead of Employee
def get_person_display(p: Person):
return p.greet()
Can we pass this function as a parameter to process_people
? Lets try
people: list[Employee] = [Employee("Anjali", 1), Employee("Aparna", 2)]
displays = process_people(people, get_person_display) # ✅ OK
print(displays) # ['Hello Anjali', 'Hello Aparna']
It works! mypy
does not give any error, and the code runs.
We can see why it works: process_people
will be passing Employee
objects as input to the fn
parameter. Any function that can process Employee
can be used here – that means any function that takes input as Employee
or any of its parent types.
This shows that the Callable
type is contravariant with respect to the input types.
On the other hand, Callable
is covariant with respect to the return types. Which means we can use Callable[[str], Employee]
in a place where Callable[[str], Person]
is expected.
Summary
To summarise, the concept of variance explains when one type can be substituted with another type
- Type variables that are covariant can be substituted with a more specific type without causing errors
- Type variables that are contravariant can be substituted with a more general type without causing errors
- Types where neither is possible are invariant
Among the common use cases in Python, the following is the behaviour
- Normal classes and types are covariant
- Mutable container types are invariant
- Read-only container types are covariant
- Function types are contravariant with respect to the input types
- Function types are covariant with respect to the output type
Many programmers intuitively understand that you can use a Child
type where a Parent
type is expected. So they get confused when they try to substitute list[Child]
in a place where list[Parent]
is required and get errors. Hopefully this article sheds some light on the concept.
Did you like this article?
If you liked this article, consider subscribing to this site. Subscribing is free.
Why subscribe? Here are three reasons:
- You will get every new article as an email in your inbox, so you never miss an article
- You will be able to comment on all the posts, ask questions, etc
- Once in a while, I will be posting conference talk slides, longer form articles (such as this one), and other content as subscriber-only