logo le blog invivoo blanc

Les dataclasses en python

24 septembre 2020 | Python | 1 comment

Introduit en Python 3.7 à la suite du PEP 557, le mécanisme des dataclasses est une petite boîte à outils permettant de simplifier l’écriture de quelques éléments de base de la définition des classes, et ainsi d’améliorer leur lisibilité.

Premier abord

Ces « quelques éléments de bases » des classes concernent en fait leurs attributs (leurs data, d’où le nom data classes). Si bien que si nous souhaitons définir une classe qui ne fait à peu près qu’héberger des attributs, nous pourrons l’écrire en seulement quelques lignes (sous la forme la plus simple : le nombre d’attributs + 2 lignes) tout en ayant pu définir certains aspects élémentaires du comportement des futures instances (à propos de leur représentation sous forme de chaîne de caractères, de leur mutabilité, et d’autres choses).

Imaginons que nous ayons à écrire une classe Foo dont les instances ne contiendront que trois attributs a, b et c. Nous devrons écrire au minimum une méthode __init__ :

>>> class Foo:
...     
...     def __init__(self, a, b, c):
...         self.a = a
...         self.b = b
...         self.c = c
... 
>>> Foo(1, "b", True).__dict__
{'a': 1, 'b': 'b', 'c': True}

Ce type de méthode __init__, où on ne fait rien d’autre qu’initialiser des attributs avec les données reçues par le constructeur, est assez courant et peut se répéter plusieurs fois dans un code. Ce n’est pas que ce soit très long, mais dans ce cas canonique déjà, les dataclass peuvent alléger le code. La création d’une dataclass se fait via un décorateur du même nom qui doit être importé, comme tous les outils qui seront reliés à cette notion, du module dataclasses. Le code permettant de créer la dataclass Foo est alors :

>>> from dataclasses import dataclass
>>> 
>>> @dataclass
...     class Foo:
...         a: int
...         b: str
...         c: bool
...     
>>> Foo(1, "b", True).__dict__
{'a': 1, 'b': 'b', 'c': True}

Comme on le voit, créer une dataclass se fait en listant simplement les attributs qui la composent. Il est néanmoins important de noter que ce système nous impose de typer ces attributs, à l’aide de la syntaxe des annotations de variables disponible depuis le PEP 526. L’écriture sous forme de dataclass a donc cet avantage de garantir la présence du typage, et on a l’impression de lire une interface plus claire qu’auparavant.

Malheureusement, les annotations dans les dataclass ne sont pas utilisées pour faire de la validation. Il est donc tout à fait possible de ne pas respecter l’intention du créateur de la classe et d’écrire :

>>> Foo(1, 2, 3).__dict__
{'a': 1, 'b': 2, 'c': 3}

Ce comportement respecte celui des PEP 484 et 526, pour lesquels ces annotations sont avant tout créées pour être vérifiées lorsque des vérificateurs de type (par exemple mypy) sont exécutés sur le code source, pas lors des exécutions normales de notre code (ce qui a l’avantage de ne pas ralentir l’exécution en production).

Comportement global des dataclasses

            De base, la dataclass que nous avons écrite fournit en réalité quelques fonctionnalités supplémentaires : la comparabilité entre les instances et une représentation basique sous forme de chaîne de caractères.

>>> Foo(1, "b", True) == Foo(1, "b", True)
True
>>> Foo(1, "a", True) != Foo(1, "b", True)
True
>>> Foo(1, "a", True) == Foo(1, "b", True)
False
>>> repr(Foo(1, "b", True))
"Foo(a=1, b='b', c=True)"

Le décorateur dataclass ne s’est donc pas contenté de nous fournir un __init__ automatique. En réalité notre classe décorée équivaut plutôt au code suivant :

class Foo:

    def __init__(self, a: int, b: str, c: str):
        self.a = a
        self.b = b
        self.c = c

    def __repr__(self):
        return f"Foo(a={self.a!r}, b={self.b!r}, c={self.c!r})"

    def __eq__(self, other):
        if other.__class__ is not self.__class__:
            return NotImplemented
        return (self.rank, self.suit) == (other.rank, other.suit)

Insistons bien sur ce point : les classes décorées avec @dataclass restent des classes tout à fait classiques ! Il est donc tout à fait possible de leur adjoindre d’autres méthodes par exemple. Si on trouve que la description donnée par la __repr__ automatique n’est pas terrible, libre à nous de la modifier ou d’ajouter une méthode __str__ qui nous ira bien.

>>> @dataclass
...     class Foo:
...         a: int
...         b: str
...         c: bool
...         
...         def __str__(self):
...             return f"I'm a Foo with a={self.a}, b={self.b} and c={self.c}."
...         
>>> str(Foo(1, "bar", True))
"I'm a Foo with a=1, b=bar and c=True."

Configuration de ce comportement

            Ce que nous avons vu des dataclasses jusqu’ici est en réalité leur comportement par défaut. Elles peuvent fournir plus (ou moins) de fonctionnalités selon les arguments qui seront passés au décorateur dataclass, car celui-ci peut être utilisé aussi bien comme une fonction produisant un décorateur. Par exemple, pour compléter les comparaisons d’égalité entre deux instances, on peut créer toutes les autres méthodes de comparaison : __lt__, __le__, __gt__ et __ge__ (respectivement pour les opérateurs <,, > et ). Pour ce faire, on écrira :

>>> @dataclass(order=True)
...     class Foo:
...         a: int
...         b: str
...         c: bool
…
>>> Foo(1, "bar", True) <= Foo(1, "bam", True)
False
>>> Foo(1, "bar", True) <= Foo(1, "baz", True)
True

La comparaison se fait attribut par attribut, dans leur ordre de définition, comme s’il s’agissait de comparer deux tuples.

Afin de voir toutes les possibilités de personnalisation d’une dataclass, utilisons maintenant la signature de cette fonction :

>>> @dataclass(init=True, repr=True, eq=True, order=False, unsafe_hash=False, frozen=False)
...     class Foo:
...         a: int

À cette lecture, on comprend rapidement que les 3 premiers arguments correspondent au comportement par défaut que nous avons vu jusqu’ici, c’est-à-dire le fait de générer automatiquement, respectivement, une méthode __init__, une méthode __repr__ et une méthode __eq__. Nous avons également vu à quoi servait l’argument order, dont on voit qu’il n’est pas actif par défaut. Concernant les deux derniers :

  • frozen permet de créer des instances immuables, c’est-à-dire que leurs attributs sont en lecture seule.

unsafe_hash permet de forcer la génération d’une méthode __hash__ dans des cas très particuliers. Normalement, une dataclass sait quand elle peut et doit générer une méthode __hash__ ou non, principalement en regardant les valeurs des options eq et frozen. Dans certains cas, si on est sûr de ce qu’on fait, on peut demander à forcer, malgré tout, la génération de __hash__. La documentation officielle explique ceci plus en détail. Dans le doute, il est recommandé de ne pas activer cette option

Configuration des attributs des dataclasses

Outre la configuration de la classe dans sa globalité, il est possible de raffiner la définition des objets au niveau de chacun de leurs attributs. Par exemple, certains attributs pourraient ne pas devoir être renseignés à l’initialisation mais plutôt après celle-ci, avoir des valeurs par défaut issues d’appels à des fonctions, ne pas devoir être pris en compte dans les comparaisons entre objets, etc.

Pour cela, le module met à disposition une fonction field() à utiliser de la façon suivante :

>>> from dataclasses import dataclass, field
>>>
>>> @dataclass
...     class Foo:
...         a: int = field(repr=False)
...         b: str
...         c: bool
...         
>>> str(Foo(1, "bar", True))
"Foo(b='bar', c=True)"

Dans cet exemple, l’attribut a est modifié par la fonction field de manière à ne pas apparaître dans la représentation par défaut (le résultat de __repr__) générée par le décorateur dataclass. On voit que ce comportement est régi par l’argument repr de la fonction field, dont la valeur par défaut est True.

Voici la liste complète des arguments possibles :

  • default : permet de choisir une valeur par défaut de l’attribut,

  • default_factory : permet de passer un objet callable qui sera appelé sans argument pour initialiser l’attribut,

  • init : si True (par défaut), l’attribut sera dans la signature de __init__,

  • repr : si True (par défaut), l’attribut fera partie de la représentation par défaut,

  • compare : si True (par défaut), l’attribut fera partie de ceux utilisés pour comparer deux objets,

  • hash : accepte un booléen ou None (par défaut), et permet de forcer l’attribut à faire partie des valeurs utilisées par la méthode __hash__ automatiquement générée, ce qui n’est pas recommandé (la valeur par défaut, None, inclura l’attribut dans l’algorithme de hachage seulement s’il est aussi utilisé pour les comparaisons, c’est-à-dire que cela dépend de l’argument précédent, et c’est bien ce qu’on veut a priori),

  • metadata : None par défaut, permet de passer un mapping pour attacher d’autres informations à cet attribut, qui ne seront pas utilisées par la dataclass elle-même mais pourront l’être par d’autres bibliothèques ou le reste du programme.

En ce qui concerne la valeur par défaut, on ne peut évidemment pas préciser à la fois les arguments default et default_factory (sous peine de générer une erreur). De plus, il est utile de savoir qu’il n’est pas nécessaire d’utiliser la fonction field pour préciser seulement une valeur statique par défaut, en laissant toutes les autres options à leur valeur par défaut. Pour faire ça, il est bien plus simple d’écrire :

>>> @dataclass
...     class Foo:
...         a: int
...         b: str
...         c: bool = True
...         
>>> Foo(1, "b")
Foo(a=1, b='b', c=True)

Il y a également deux contraintes à garder à l’esprit :

  • les attributs ayant une valeur par défaut ne peuvent pas être placés avant d’autres qui n’en ont pas,

  • pour utiliser une valeur par défaut mutable (par exemple : [], set() ou {}), il est nécessaire de recourir à une default_factory, car autrement cette valeur serait partagée entre toutes les instances de la classe.

Quelques outils supplémentaires

Le post-init

Il arrive très souvent que l’initialisation d’un objet ne consiste pas seulement à affecter des valeurs à des attributs, ce que se contente de faire la méthode __init__ automatiquement générée. Pour ajouter d’autres traitements, le décorateur dataclass nous permet d’utiliser une méthode spéciale appelée __post_init__ qui sera appelée juste après __init__ sans recevoir d’autre argument que self (puisque les valeurs initiales des attributs s’y trouvent déjà). Par exemple, si l’on souhaite initialiser un attribut à une valeur qui dépende d’un ou plusieurs autres attribut(s), on peut écrire :

>>> @dataclass
...     class Foo:
...         a: int
...         b: str
...         c: bool = field(init=False)
...     
...     def __post_init__(self):
...         self.c = len(self.b) > self.a
...         
>>> Foo(1, "b")
Foo(a=1, b='b', c=False)
>>> Foo(1, "foo")
Foo(a=1, b='foo', c=True)

Les attributs de classe

Si on souhaite créer un attribut lié à la classe et non uniquement à une instance, on remarque qu’il y a un problème puisqu’on l’écrirait au même endroit que les attributs d’instance et on ne pourrait pas le distinguer. Ce cas d’usage est prévu par le mécanisme de dataclass. Pour ce faire, il suffit d’utiliser l’annotation correcte prévue par le PEP 526, c’est-à-dire ClassVar. C’est un des cas pour lesquels dataclass a besoin d’inspecter les annotations pour savoir ce qu’il doit faire, à savoir, ici, exclure les attributs annotés avec ClassVar de ses traitements. Exemple :

>>> from dataclasses import dataclass
>>> from typing import ClassVar
>>>
>>> @dataclass
...     class Foo:
...         a: int
...         b: str
...         c: ClassVar[bool] = False
...         
>>> Foo.a
Traceback (most recent call last):
  File "<input>", line 1, in <module>
AttributeError: type object 'Foo' has no attribute 'a'
>>> Foo.c
False
>>> Foo(1, "b")  # test de la dataclass pour voir si 'c' est utilisé (réponse : non)
Foo(a=1, b='b')

Les variables servant seulement à l’initialisation

Il peut aussi arriver qu’on ait besoin, lors de l’initialisation, de passer une valeur qui ne sera pas à sauvegarder dans un attribut. Pour cela, le module dataclasses prévoit une annotation spécifique : InitVar. Une telle valeur sera propagée à __post_init__ pour qu’on puisse faire le traitement souhaité. Illustration :

>>> from dataclasses import InitVar, dataclass
>>>
>>> @dataclass
...     class Foo:
...         a: int
...         b: str
...         c: InitVar[bool] = False  # valeur par défaut de la variable d’initialisation
...
...     def __post_init__(self, c: bool):
...         # On multiplie self.a par 2 si 'c' est True
...         if c:
...             self.a *= 2
...         
>>> Foo(1, "b")
Foo(a=1, b='b')
>>> Foo(1, "b", c=True)
Foo(a=2, b='b')

Inspecter les attributs d’une dataclasse

Enfin, citons la fonction fields() mise à disposition par le module dataclasses pour accéder aux attributs gérés par la dataclass (c’est-à-dire en omettant les champs du type ClassVar ou InitVar par exemple). Cette fonction accepte pour unique argument une dataclass ou une instance de dataclass, et son retour est un tuple d’objets de type Field (la classe utilisée en interne pour définir les attributs à gérer). Démonstration (en raccourcissant les retours dans le texte via […]) :

>>> from dataclasses import InitVar, dataclass, fields
>>>
>>> @dataclass
...     class Foo:
...         a: int
...         b: str
...         c: InitVar[bool] = False  # valeur par défaut de la variable d’initialisation
...
...     def __post_init__(self, c: bool):
...         # On multiplie self.a par 2 si 'c' est True
...         if c:
...             self.a *= 2
...         
>>> fields(Foo)
(Field(name='a',type=<class 'int'>,[…]), Field(name='b',type=<class 'str'>,[…]))
>>> fields(Foo(1, "b", True))
(Field(name='a',type=<class 'int'>,[…]), Field(name='b',type=<class 'str'>,[…]))
>>> fields(Foo(1, "b", True)) == fields(Foo)
True
>>> fields(object())
Traceback (most recent call last):
  […]
AttributeError: 'object' object has no attribute '__dataclass_fields__'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  […]
TypeError: must be called with a dataclass type or instance

Conclusion

 Nous avons donc vu ce qu’est une data class, à quel point cet outil peut nous permettre de rendre le code d’une classe plus léger et plus lisible, et nous avons couvert l’essentiel des outils permettant de faire tout ce que nous pourrions autrement faire avec une classe normale.

Nous n’avons pas mentionné la question de l’héritage pour ne pas trop allonger le texte, mais sachez que vous pouvez en faire sans souci. Il faut juste faire attention à l’ordre des attributs dans la relation d’héritage (pour plus d’informations, n’hésitez pas à consulter la documentation officielle ou le code). De même, l’utilisation de __slots__ ne pose pas de problème particulier. En revanche, il manque à l’heure actuelle une manière simple et totalement propre de rajouter une property à une dataclass, ce qui est à ma connaissance leur principal inconvénient.

Personnellement, je recommande vivement de les utiliser le plus possible pour améliorer la lisibilité de votre code.

Retrouvez toutes nos bonnes pratiques sur Python sur notre blog !