🐍 Avoir des type plus prĂ©cis en Python

| ~ 8 mins | 1657 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 ↩︎