🐍 Avoir des type plus précis en Python
Introduction
En python, le typage est dynamique fort. De sorte que :
a = "super"
b = 12
c = a + b
#Traceback (most recent call last):
# File "………………………….", line 3, in <module>
# c = a + b
# ~~^~~
#TypeError: can only concatenate str (not "int") to str
Python est capable à la fois de savoir que “a” est une
chaine de caractères à l’exécution (typage dynamique) mais
reste pour autant capable d’interdire à l’exécution certaines
opérations pour des raisons d’incompatibilité de type (d’où
le fort
).
Il se trouve que depuis un certain nombre d’années, une nouvelle “mode” lié à des besoins de clarté et validation du code a poussé le monde python tout comme celui d’autre langage dynamique à proposer des solutions pour permettre d’être plus explicite.
En python, c’est la PEP 0484 ajoutant les « annotations de type » dans Python. Ces annotations ne changent rien à l’exécution du langage sauf que :
- Elles permettent de documenter efficacement du code.
- Elles peuvent être vérifiées statiquement par des outils externes tels que mypy, ou directement dans votre IDE préféré.
- Elles peuvent être vérifiées dynamiquement via des librairies de validation, c’est le cas de Serpyco et de Pydantic au contraire de Marshmallow1.
Bref, ces annotations peuvent nous rendre un fier service :
- En détectant des erreurs, avant l’exécution du code.
- En forçant à être plus explicite sur ce qu’on veut, si on s’oblige à les utiliser.
Avoir des types plus précis ?
Avec tout cas, vient une idée. Comment avoir des types les plus précis possible afin de limiter au maximum les bugs ? Les types en Python sont souvent avant tout une affaire de comportement, mais il arrive parfois qu’on aimerait être un peu plus strict sur ce qu’on en attend d’eux : Valider une propriété supplémentaire, clarifier une certaine sémantique.
Méthodes
1. Commentaire
La première solution consiste à utiliser les commentaires pour clarifier l’usage :
- ✅ Simple à utiliser et à comprendre.
- ✅ Utilisable pour tout : Sémantique/Contraintes à respecter
- ❌ aucune validation statique possible.
- ❌ aucune validation dynamique possible.
- ⚠ La coloration syntaxique ne met pas forcément très en avant la documentation.
import time
# variable:
one_minute: int = 60 # in seconds
# function:
def wait(duration: int) -> None:
"""
:param duration: in seconds
:return: nothing
"""
time.sleep(duration)
2. Programmation par contrat(assert)
Une solution alternative pour définir des contraintes est la programmation par contrat, par exemple par l’usage d’assert. À noter que des solutions plus complètes existent notamment la librairie deal:
- ✅ Simple à utiliser et à comprendre.
- ✅ Utilisable pour définir des contraintes à respecter
- ✅ validation dynamique.
- ❌ pas utilisable pour ajouter de la sémantique
- ❌ aucune validation statique possible.
- ⚠ Un peu trop dissocié de la définition de la variable à mon goût.
import time
# function:
def wait(duration: int) -> None:
assert duration > 0
time.sleep(duration)
3. Annotated
Une solution, consiste à utiliser
Annotated: Une certaine “normalisation” de cas simple
existe avec, la librairie annotated_types
Il est donc possible de définir par exemple facilement si l’on veut :
- ✅ Utilisable pour tout : Sémantique/Contraintes à respecter, mais bien plus adapté pour des contraintes à mon avis.
- ❌ Très verbeux, et syntaxe redondante en cas de nombreuse utilisation.
- ⚠ validation statique et dynamique envisageable si usage d’une syntaxe génériquement admise2.
import annotated_types
one_minute: Annotated[int, annotated_types.Gt(0)] = 60
def wait(duration: Annotated[int, annotated_types.Gt(0)]) -> None:
time.sleep(duration)
4. Alias de type
Les alias de type sont une autre fonctionnalité envisageable :
- ✅ Permet facilement de simplifier un type complexe.
- ❌ Sémantiquement pas idéal pour définir une contrainte supplémentaire ni une distinction.
- ❌ L’existence de 3 syntaxes différentes rend les choses un peu confuses.
- ❌ validation statique et dynamique impossible :
- pas de distinction de type réelle
PositiveInt = int
et doncint = PositiveInt
, la validation ne vérifiera rien de pertinent. isinstance
ne fonctionne pas avec la syntaxe 3 utilisanttype
.
- pas de distinction de type réelle
from typing import TypeAlias
# syntax 1: Implicit
PositiveInt = int
# syntax 2: Explicit
PositiveInt: TypeAlias = int
# syntax 3 (python 3.12)
# type PositiveInt = int
def wait(duration: PositiveInt) -> None:
time.sleep(duration)
5. NewType
Pour distinguer des types, il existe NewType, une sorte de sous-classe fantôme.
- ✅ Pratique pour éviter de mélanger des données sémantiquement différentes, mais de même type.
- ✅ validation statique possible.
- ⚠ Nommage à faire avec précaution comme pour les sous-classes, selon le cas, il peut être pertinent de cacher ou de rendre clairement visible dans le nommage le type original. Attention à ne pas cacher le type original alors que l’utilisateur peut en avoir besoin.
- ⚠ Un peu complexe d’utilisation, car pas un vrai type.
- ❌ validation dynamique impossible (pas un vrai type, `isinstance non opérant).
- ❌ ne permet pas d’ajouter des contraintes.
import time
from typing import NewType
SecondsInt = NewType('SecondsInt', int)
one_minute: SecondsInt = SecondsInt(60)
def wait(duration: SecondsInt) -> None:
time.sleep(duration)
6. Sous-classe
Une solution simple, mais efficace est tout simplement de créer des sous-classes spécifiques
- ✅ facile à utiliser
- ✅ utilisable pour à la fois de la sémantique et des contraintes de valeurs
- ✅ validation statique et dynamique possible
- ⚠ Potentiellement coûteux (vrai type)
import time
class SecondsInt(int):
pass
one_minute: SecondsInt = SecondsInt(60)
def wait(duration: SecondsInt) -> None:
time.sleep(duration)
Résumé
facile à l’usage | distinction sémantique | ajout de contraintes | validation statique | validation dynamique | simple à l’oeil | usage | coût en performance nul | |
---|---|---|---|---|---|---|---|---|
commentaire | ✅ | 🆗 | 🆗 | ❌ | ❌ | ✅ | clarification | ✅ |
assertions | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ | contraintes | ❌ |
Annotated | ✅ | ✅ | ✅ | 🆗 | ✅ | ❌ | contraintes | ✅ |
TypeAlias | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | simplification | ✅ |
NewType | ❌ | ✅ | ❌ | ✅ | ❌ | ✅ | distinction sémantique | ✅ |
Sous-Classe | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | Sémantique et contraintes | ❌ |
Il n’y a pas de solution parfaite, la solution idéale peut notamment être une combinaison de solutions.
Voici quelque exemple concrêt et les solutions que j’utiliserais à ce jour:
Contrainte sur les entiers
Un cas simple est de vouloir un entier positif, une
solution simple si vous utilisez pydantic pour d’autres
raisons et de récupérer le type PositiveInt
:
# in pydantic.types
import annotated_types
...
PositiveInt = Annotated[int, annotated_types.Gt(0)]
Notons que dans le cas de l’implémentation de pydantic ce n’est pas un NewType, donc comme il n’est pas garanti que l’analyser statique comprenne la contrainte supplémentaire, un code de la sorte peut être déclaré valide :
from pydantic.types import PositiveInt
a: PositiveInt = -12
Pydantic lui est capable de vérifier PositiveInt, mais seulement durant l’exécution du programme si on lui demande explicitement.
Un autre cas similaire que j’ai eu était de trouver une solution pour représenter la contrainte d’un type Rust dans du code python utilisant pyo3:
from annotated_types import Interval
# Unsigned Int 8
MAX_U8 = 2**8 - 1
MIN_U8 = 0
U8 = NewType("U8", Annotated[int, Interval(ge=MIN_U8, le=MAX_U8)])
Id de type existant ?
Les identifiants sont un exemple de distinction sémantique de type sans nécessairement ajout de contrainte.
L’exemple de NewType
dans la documentation
officielle contient cela :
UserId = NewType('UserId', int)
def get_user_name(user_id: UserId) -> str:
...
La fonction peut paraitre moyennement intéressante notamment par la redondance de “user_id”.
C’est intéressant si vous déplacez beaucoup une valeur sans vous soucier détail de l’implémentation du contenu. Ce qui arrive souvent avec les identifiants. En réduisant la surface de code utilisant les spécificités du type, ont s’évite l’incompréhension (“UserId est de type int ou str ?”).
Voici un exemple d’usage, que je pourrais avoir :
from dataclasses import dataclass
from pydantic.types import PositiveInt
UserId = NewType('UserId', PositiveInt)
RoomId = NewType('RoomId', PositiveInt)
@dataclass
class User:
user_id: UserId
name: str
...
@dataclass
class Room:
room_id: RoomId
name: str
description: str
...
class HTTPRequestData:
...
def get_current_user_id(self) -> UserId:
return UserId(self['user_id'])
def get_current_room_id(self)-> RoomId:
return RoomId(self['room_id'])
class DB:
...
def get_user(self, user_id: UserId) -> User:
return db.query(User).sql("select * from user where id = {}", user_id)
def get_room(self, room_id: RoomId) -> Room:
return db.query(Room).sql("select * from room where id = {}", room_id)
Le cas des unités de mesure.
Un cas intéressant est celui des unités de mesure telle que la seconde ou encore le millimètre. Les unités de mesure ont un sens et il existe toute une logique de conversion entre elles. Ainsi des objets spécifiques, à l’aide de sous-classe semble la solution le plus adaptés. Il existe des librairies python à cet usage, tel que pint ou encore astropy, mais n’en ayant testé aucun, je ne pourrais pas vous conseiller.
Pour des cas plus simple. Les solutions que le privilégierait seraient NewType ou encore des commentaires :
import time
from typing import NewType
SecondsInt = NewType('SecondsInt', int)
one_minute: SecondsInt = SecondsInt(60)
def wait(duration: SecondsInt) -> None:
time.sleep(duration)
wait(one_minute)
Notons que contrairement aux identifiants, dans ce cas présent, je préfère être précis sur le type pour plus de clarté.
Cela dit selon votre cas, dans le cas précis de la durée de temps, il peut être plus pertinent d’utiliser timedelta :
import time
from datetime import timedelta
one_minute: timedelta = timedelta(minutes=1)
def wait(duration: timedelta) -> None:
time.sleep(duration.total_seconds())
wait(one_minute)
Pour Conclure
Difficile de conseiller un usage spécifique, bien que j’aime beaucoup Annotated, TypeAlias et NewType, ils ne conviennent malheureusement pas à tous les cas d’usage. Je trouve néanmoins le combo NewType/Annotated intéressant, en espérant que mypy et autre seront capable de gére les contraintes définies dans annotated_types dans un futur proche.
Quant À TypeAlias, je reste plus que mitigé sur les cas d’usage pertinent :
- Si Le nom de type est trop court, l’intérêt est nul.
- Si on commence à utiliser des types complexes tel que
Dict[str, Union[int, float, str]]
, ne faudrait-il pas considérer sérieusement utiliser un type spécifique, un objet spécifique, ou peut-être unTypedDict
?
Allez plus loin :
Quelques ressources intéressantes sur le sujet que j’ai consulté :
- https://justincaustin.com/blog/python-typing-newtype/ : Sur la différence NewType/TypeAlias.
- https://www.agest.am/phantom-types-in-python : Parle notamment de la librairie phantom-types qui propose un mécanisme de contrainte avec des types “fantômes”.
-
Marshmallow est une librairie plus ancienne que l’instauration des annotations de type en Python, elle n’a pas pu utiliser cette syntaxe ↩︎
-
https://github.com/annotated-types/annotated-types/issues/33 ↩︎