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.

💡
A data type is covariant if it can be safely substituted with a more specific type.

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.

💡
A data type is invariant if it cannot be substituted with a more specific type, and it also cannot be substituted with a more general type

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.

💡
A data type is contravariant if it can be substituted with a more general type

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:

  1. You will get every new article as an email in your inbox, so you never miss an article
  2. You will be able to comment on all the posts, ask questions, etc
  3. Once in a while, I will be posting conference talk slides, longer form articles (such as this one), and other content as subscriber-only

Tagged in: