🐍 Avoir des type plus précis en Python

| ~ 8 mins | 1704 mots

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 :

Bref, ces annotations peuvent nous rendre un fier service :

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 :

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:

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 :

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 :

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.

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

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 :

Allez plus loin :

Quelques ressources intéressantes sur le sujet que j’ai consulté :


  1. Marshmallow est une librairie plus ancienne que l’instauration des annotations de type en Python, elle n’a pas pu utiliser cette syntaxe ↩︎

  2. https://github.com/annotated-types/annotated-types/issues/33 ↩︎