Avez-vous déjà travaillé sur une très grande base de code, mélangeant choix historiques et nouvelles technologies ? C’est parfois difficile de comprendre toutes les intentions en parcourant un programme. A titre personnel, il m’est arrivé de me retrouver à lire des centaines de lignes de code python où étaient utilisées des classes, des dataclasses et des dictionaries un peu partout. Je n’étais pas capable de comprendre quels étaient les avantages à utiliser un TypedDict plutôt qu’une dataclass. Tout se ressemblait, et le code dans lequel je plongeais n’exprimait pas la différence. Cela se produit régulièrement, si on ne fait pas attention à bien remanier son code lors de l’ajout d’une fonctionnalité ou lors d’une résolution de bugs, le code devient alors plus complexe et perd en lisibilité.

Dans cet article nous allons essayer de comprendre un peu mieux pourquoi utiliser un TypedDict ou une dataclass, quels sont leurs atouts et comment mieux concevoir notre code grâce au typage.

Tout d’abord, parlons des types

Chez Deepki, il y a deux ans, nous avons commencé à implémenter le typage pour nous aider à améliorer la compréhension et la qualité de notre code.

Le typage a été introduit dans Python via la PEP 484 :

Cette PEP vise à fournir une syntaxe standard pour le typage, ouvrant ainsi le code Python à l’analyse statique, à un remaniment du code plus simple, à une éventuelle vérification des types à l’exécution, et (dans certain cas) à la génération de code utilisant l’information de type.

Parmi ces objectifs, l’analyse statique est le plus important. Cela inclut le support des validateurs de types tels que mypy, ainsi que la fourniture d’une notation standard qui peut être utilisée par les IDEs pour la complétion et le remaniement de code.

Pour rappel, voici un exemple de définition de type d’une fonction :

# Sans typage
def find_user(name, age):
    ...
    return User(**doc)

# Avec typage
def find_user(name: str, age: int) -> User:
    ...
    return User(**doc)

Le typage permet aux développeurs de débusquer les bugs plus tôt, en utilisant des outils tels que mypy qui révèlent des anomalies lorsque les types ne sont pas respectés. Ces indications permettent de gagner beaucoup de temps plutôt que de découvrir les erreurs lors de l’exécution du programme.

Chez Deepki, nous avons même configuré nos éditeurs de code pour attraper ces erreurs et les afficher directement dans le code :

VSCode affiche des erreurs de typage

Ça nous fait gagner un temps fou !

À propos des classes et dataclasses

Le typage fonctionne très bien avec les classes.

Prenons un exemple :

from dataclasses import dataclass

@dataclass
class User:
    name: str
    city: str

@dataclass
class City:
    name: str
    is_polluted: bool

def serialize_user_city_pollution(user: User, city: City) -> str:
    air_status = "polluted" if city.is_polluted else "clean"
    return f"{user.Name} lives in {user.city} where the air is {air_status}" # <- 1st error

bob = User(name = "Bob", city = "Paris")
paris = City(id = "Paris", is_polluted = True) # <- 2nd error

print(serialize_user_city_pollution(bob, paris))

En précisant le type des entrées de la fonction, le code est plus compréhensible. On sait tout de suite avec quelles entités on travaille.

De plus, comme dit précédemment, il est possible d’exécuter mypy sur ce fichier ou de configurer son éditeur pour découvrir ces erreurs directement :

$ mypy file.py
area.py:10: error: "User" has no attribute "Name"; maybe "name"?
area.py:10: error: "City" has no attribute "id"
Found 2 errors in 1 file (checked 1 source file)

TypedDict

Quelle est la différence entre TypedDict et un dictionnaire ?

TypedDict est très similaire à un dictionnaire, avec une particularité supplémentaire : les clés sont définies à l’avance, ainsi que le type de chaque valeur. En réalité TypedDict est un protocol Python qui permet de typer les valeurs d’un dictionnaire (cf la PEP 544 : Protocols: Structural subtyping (static duck typing)).

Travailler avec TypedDict

Voici quelques avantages à utiliser TypedDict :

1. Cela permet de typer une variable avec un dictionnaire dont on connait les propriétés et leur type.

class User(TypedDict):
    name: str
    age: int

def is_old(user: User) -> bool:
    return user["age"] > 70

C’est très utile quand on essaye de typer du code existant qui utilise des dictionnaires comme structure de données. Par exemple, lors d’un appel vers une API externe dont la réponse renvoyée est au format JSON, l’objet reçu peut être typé avec un TypedDict. Ainsi le contrat de l’API est respecté. Néanmoins il convient de valider les données, afin que le typage reste toujours vrai, même si l’API évolue.

Avoir ces structures de données typées vous aidera à avoir plus de contrôle sur votre code.

Une autre possibilité serait de remplacer ces modèles par des classes, cependant cela implique de remanier toutes les fonctions et classes qui les utilisent. Ce qui peut devenir très long, voire très compliqué, et parfois impossible.

C’est ce à quoi on est souvent confronté chez Deepki : des milliers de lignes de code héritées de plusieurs années de développement utilisant la plupart du temps des dictionnaires comme structure de données primaire. Ces données proviennent de plusieurs sources :

  • Points d’accès à l’API de notre client web
  • Fichiers CSV et Excel
  • Fichiers YAML pour notre configuration
  • Bibliothèques externes (ex: pandas)…

Nous sommes également confrontés à cela lorsque nous convertissons des dataframes en pur objet python. L’utilisation de TypedDict permet le typage de ces derniers afin de garantir après validation que l’on maitrise les données manipulées.

2. Cela traite les valeurs None comme optionnelles

class User(TypedDict):
    name: str
    age: int
    email: str | None

User(name="Bob", age=32, email=None)
# Pas besoin de donner la valeur "None" pour l'email
print(User(name="Bob", age=32))

Ce programme affiche User(name="Bob", age=32).

En pratique, c’est très utile surtout pour les structures de données qui ne sont pas fixes.

TypedDict chez Deepki

Chez Deepki, nous utilisons TypedDict pour typer notre code un peu ancien sans devoir modifier des milliers de lignes. Ce qui est parfois notre seule option.

On peut également l’utiliser lorsque l’on souhaite manipuler des structures de données plus flexibles, comme c’est le cas avec la configuration par exemple. Avec de la configuration externe, on travaille généralement avec de nombreuses propriétés possédant des valeurs par défaut. Ainsi on n’a pas besoin de les conserver dans notre entité courante et les valeurs None de la structure de données sont retirées. Cela nous permet d’avoir plus de contrôle sur les données provenant de fichier YAML ou JSON en y ajoutant des types.

Pourquoi choisir une dataclass plutôt que TypedDict ?

Bien que ces deux structures de données se ressemblent, leur utilisation impose une approche de développement fondamentalement différente.

D’un coté les dataclasses imposent un cadre strict, cela nécessite donc de réfléchir à la structure des données en amont. A l’inverse, l’utilisation de TypedDict, de par son aspect beaucoup plus flexible, permet d’avancer et de se poser les questions au fil du développement, voire a postériori.

Nous allons voir néanmoins quelques avantages à utiliser les dataclasses.

Comparer les types des objets

Avec TypedDict il est difficile d’utiliser isinstance(), car isinstance compare la classe de l’objet, pas celle de ses attributs.

Or c’est possible avec les dataclasses.

Par exemple :

@dataclass
class User():
    name: str
    age: int

@dataclass
class ProjectUser():
    name: str
    age: int

def is_user(o: Union[User, ProjectUser]) -> bool:
    return isinstance(o, User)

print(is_user(ProjectUser(name="Bob", age=32))) # False
print(is_user(User(name="Bob", age=32))) # True

Pouvoir comparer des objets entre eux peut se révéler très utile, par exemple pour valider des données en entrée.

Python 3.10 introduit Type Guards dans la PEP 647. Ce qui nous permet de spécifier le type d’une expression et donc de définir un type global pour un dictionnaire.

Par exemple :

class User(TypedDict):
    name: str
    age: int

class ProjectUser(TypedDict):
    name: str
    age: int

def is_user(o: dict) -> "TypeGuard[User]":
    try:
        return isinstance(o["name"], str) and isinstance(o["age"], int)
    except KeyError:
        return False

print(is_user(User(name="Bob", age=32))) # True
print(is_user(ProjectUser(name="Bob", age=32))) # True

Cependant, comme le montre cet exemple, le type d’un dictionnaire dépend du type de ses attributs, et il n’est donc pas possible de différencier un User d’un ProjectUser, comme c’était le cas avec l’utilisation des dataclasses.

De plus, lors de l’instanciation d’un TypedDict ou d’une dataclasse, il ne faut pas oublier que les vérifications de types ne se font pas lors de l’exécution du code. Ainsi il est possible d’instancier un objet avec des attributs qui ne respectent pas le typage :

Le code s’exécute sans soucis, malgré les problèmes détectés par mypy.

La comparaison sélective

Avec les dataclasses, il est possible de selectionner les attributs que l’on souhaite comparer. Pour cela il suffit de redéfinir la méthode magique __eq__, par exemple :

@dataclass
class User():
    email: str
    name: str
    age: int

    def __eq__(self, other):
        if not isinstance(other, User):
            return NotImplemented

        return self.name == other.name and self.email == other.email

user1 = User(email="bob.dylan@deepki.com", name="Bob", age=32)
user2 = User(email="bob.dylan@deepki.com", name="Bob", age=30)
print(user1 == user2) # True

En effet l’âge d’un User change chaque année et ne doit pas faire partie de la comparaison.

Tandis qu’avec les TypedDicts, la comparaison s’effectue sur tous les attributs.

L’utilisation des attributs virtuels

Un autre avantage à utiliser des dataclasses par rapport aux TypedDicts est la possibilité de définir des attributs virtuels (aussi appelés propriétés) :

@dataclass
class Foo:
    value: float

    @property
    def rounded_value(self):
        return int(value)

Cette pratique ouvre le champ des possibles et permet d’approcher un peu plus la programmation orientée objet, ainsi que tous les avantages qu’elle offre.

Conclusion

En réalité les dataclasses ne remplacent pas vraiment TypedDict, mais les deux sont complémentaires.

TypedDict est généralement une bonne solution lorsque la source des données provient de l’extérieur (JSON, YAML, …) et peut varier de structure. Il permet également de typer des dictionnaires déjà existants sans avoir à modifier tout son code pour passer par d’autres types de structures de données comme les dataclasses par exemple.

Cependant, lorsque c’est possible, utiliser les dataclasses présente ces avantages :

  • Elles permettent une déclaration explicite des attributs d’un objet tout en intégrant le typage
  • Cela donne des indices sur les types des attributs grâce aux type hints
  • Les erreurs lors de la tentative d’accès à des attributs qui n’existent pas sont plus intuitives
  • Les dataclasses peuvent être immutables (en utilisant l’argument frozen)
  • Et enfin, les dataclasses viennent avec un meilleur support pour les IDE (par exemple, renommage, complétion de code…)

Voici un tableau comparatif des principales différences entre ces deux structures de données :

  Dataclass TypedDict
Utilisation du typage
Flexibilité sur la structure de donnée
Approche de développement en amont à postériori
Comparaison entre objets
Erreurs +compréhensibles & support IDE
Programmation Orientée Object

A titre personnel, je préfère nettement utiliser des dataclasses quand c’est possible, qui rendent le code beaucoup plus clair et lisible, en manipulant des objets qui ont un sens réel dans l’application.

Chez Deepki, cette pratique ne fait que s’amplifier, et nous permet aujourd’hui d’améliorer la qualité de notre application et l’interopérabilité entre chaque développeur à travailler sur une très grande base de code.

Sources