Les Crash-Devs d'un Passionné

Variant, contravariant et invariant

/Catégorie/python

Temps de lecture : 6 minutes

Pour ceux qui utilisent le type hinting en python, vous avez surement eu une erreur sur ce bout de code

clipboard
Copier le code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Animal:
  pass

class Dog(Animal):
  pass

def feed_animals(animals: list[Animal]):
  for animal in animals:
    print(f"Feeding a(n) {type(animal).__name__}")

dogs = [Dog()]
feed_animals(dogs) # erreur mypy
Mypy: Argument 1 to "feed_animals" has incompatible type "list[Dog]"; expected "list[Animal]" [arg-type]

Ceci est tout à fait normal, car par défaut python considère les types comme invariant.

Un type invariant est un type générique qui ne peut être substitué ni par son sous-type ni par son sur-type.

On peut dire qu'un type générique est invariant quand il n'est pas de type covariant ou contravariant, on va voir ces 2 concepts juste en dessous.

Pour rappel un type générique est un type qui peut accepter d'autre type (que l'on appelle le sous-type) comme paramètre, l'exemple le plus simple est une liste.

La covariance comme solution à notre problème

Un type covariant est un type générique qui est capable de substituer son sous-type par un super-type

clipboard
Copier le code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from typing import Sequence

class Animal:
  pass

class Dog(Animal):  
  pass

def feed_animals(animals: Sequence[Animal]):
  for animal in animals:
    print(f"Feeding a(n) {type(animal).__name__}")

dogs = [Dog(), Dog()]
feed_animals(dogs) # Ceci est valide

Dans la signature

def feed_animals(animals: Sequence[Animal]):
on retrouve le type
Sequence
qui est définie par python comme un type covariant (contrairement à list)

En effet, une liste est un type mutable, donc la liste de type

list[Animal]
peut être altérée et donc ne plus respecter le contrat.

Une

Sequence
est donc la solution, ce type s'attend à avoir indifféremment une liste ou un tuple

Et le contravariant

Un type contravariant est un type générique qui est capable de substituer son super-type par un sous-type, c'est l'inverse d'un covariant.

Le type contravariant le plus connu est le callable

clipboard
Copier le code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
from typing import Callable

class A:  
  pass

class B(A):  
  pass

def process_a(data: A) -> None:
  print("Processing A")

def process_b(data: B) -> None:
  print("Processing B")

def handler(func: Callable[[A], None]):
   func(B())

handler(process_a) # Ceci est valide  
handler(process_b) # mypy émet une erreur, car process_b prend un type B, qui est plus spécifique que A.
Mypy: Argument 1 to "handler" has incompatible type "Callable[[B], None]"; expected "Callable[[A], None]" [arg-type]

Cette erreur de mypy est tout à fait normal, car un callable est un type contravariant qui ne peut pas se substituer à un autre type.

Les type génériques

À partir de la version 3.12 de python, on les note avec

T
dans cette déclaration

clipboard
Copier le code
1
2
class A[T]:
  pass

Noté que la classe A n'hérite pas de T (sinon il y aurait l'usage des parenthèses) car la synthèse utilise les

[]

Avec cette syntaxe, on indique que ce type est un covariant

clipboard
Copier le code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class Animal:
  pass

class Dog(Animal):
  pass

class Box[T]:
    def __init__(self, content: T) -> None:
        self._content = content

def play_with_dog(box: Box[Animal]):
    print(f"Play in {type(box).__name__}")

box = Box(Dog()) # Box(Animal()) is also OK
play_with_dog(box)

Dans l'exemple ci-dessus, il y a une inférence du type lorsque l'on fait

box = Box(Dog())
, on précise
content
comme type covariant.

Il est important de noter que

self._content
a une portée protected, en effet cet attribut sera uniquement utilisé dans la classe et donc mypy détermine qu'il est covariant puisque non exposé sur l'extérieur.

De plus (merci Vincent) en ajoutant une méthode (comme ci-dessous) manipulant ce même type générique, il devient invariant.

clipboard
Copier le code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class Animal:
  pass

class Dog(Animal):
  pass

class Box[T]:
    def __init__(self, content: T) -> None:
        self._content = content

    def set_content(self, content: T) -> T: #  This method infers the generic type
        self._content = content
        return self._content

def play_with_dog(box: Box[Animal]):
    print(f"Play in {type(box).__name__}")

box = Box(Dog())
play_with_dog(box) # Mypy: Argument 1 to "play_with_dog" has incompatible type "Box[Dog]"; expected "Box[Animal]"

Reprenons l'exemple sans

set_content()
, si on modifie la portée en public de
self.content
, on obtient cette fois

Mypy: Argument 1 to "play_with_dog" has incompatible type "Box[Dog]"; expected "Box[Animal]" [arg-type]

Car cet attribut est public et donc

mypy
ne garantie pas qu'il ne soit pas altéré par la suite

clipboard
Copier le code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class Animal:
  pass

class Dog(Animal):
  pass

class Box[T]:
    def __init__(self, content: T) -> None:
        self.content = content # public attribut

def play_with_dog(box: Box[Dog]): # specific class
    print(f"Play in {type(box).__name__}")

box = Box(Dog())
play_with_dog(box)

Ou alors on peut aider mypy à déterminer ce type comme covariant en ajoutant le mot clé Final

clipboard
Copier le code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from typing import Final


class Animal:
  pass

class Dog(Animal):
  pass

class Box[T]:
    def __init__(self, content: T) -> None:
        self.content : Final = content # public attribut

def play_with_dog(box: Box[Animal]):
    print(f"Play in {type(box).__name__}")

box = Box(Dog())
play_with_dog(box)

Si on est sur python < 3.12 on peut utiliser la syntaxe suivante, car sur ces versions le type est toujours invariant (et T n'est pas disponible)

clipboard
Copier le code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from typing import Generic, TypeVar

class Dog:
    pass

T_co = TypeVar('T_co', covariant=True)
class Box(Generic[T_co]):
    def __init__(self, content: T_co) -> None:
        self._content = content

def do_something(dog: Box[Dog]):
    pass

do_something(Box(Dog()))

Par défaut mypy voit

dog: Box[Dog]
comme un type invariant, avec l'utilisation de
TypeVar
on lui précise si on veut un covariant ou contravariant

En conclusion

Si un type est immuable ou si la substitution ne pose pas de risque, il est généralement covariant.

Si un type peut être modifié, il est invariant par défaut pour des raisons de sûreté.

Vous retrouverez les différents exemples classés dans un commit indépendant sur ce repo https://github.com/general03/f-variant-invariant-contravariant/commits/main/ et la documentation de mypy