Pour ceux qui utilisent le type hinting en python, vous avez surement eu une erreur sur ce bout de code
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
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]):
Sequence
En effet, une liste est un type mutable, donc la liste de type
list[Animal]
Une
Sequence
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
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
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
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())
content
Il est important de noter que
self._content
De plus (merci Vincent) en ajoutant une méthode (comme ci-dessous) manipulant ce même type générique, il devient invariant.
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()self.content
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
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
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)
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]
TypeVar
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