đ 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 ↩︎