Les data classes de Python 3.7

Une des fonctionnalités principales ajoutées en Python 3.7 est le module dataclasses, décrit dans la PEP557, et qui contient notamment l'annotation @dataclass pour créer des data classes.

L'abstract de cette PEP nous donne l'utilité de cette fonctionnalité :

This PEP describes an addition to the standard library called Data Classes. Although they use a very different mechanism, Data Classes can be thought of as "mutable namedtuples with defaults". Because Data Classes use normal class definition syntax, you are free to use inheritance, metaclasses, docstrings, user-defined methods, class factories, and other Python class features.

Vous ne vous servez pas de namedtuple() aujourd'hui et du coup l'utilité vous échappe ? OK, commençons par ça !

logo python

Retour sur namedtuple()

Quand vous faites du code, vous avez souvent besoin d'objets qui servent uniquement à stocker des données, et qui n'ont pas de méthode car pas de comportement associé. Prenons par exemple une transaction entre deux entités : on souhaite stocker les identifiants des entités et le contenu de la transaction. Il y a plusieurs solutions en Python pour implémenter un tel objet : classe, dictionnaire, tuple...

La solution la plus évidente quand on vient du monde de la POO est de faire une classe :

class Transaction:
    def __init__(self, source, destination, content):
        self.source = source
        self.destination = destination            
        self.content = content

Ca fait beaucoup de code pour juste décrire des champs... Mais il y a d'autres trucs dommages :

>>> transaction = Transaction('A', 'B', 'hello there!')
>>> print(transaction)
<__main__.Transaction object at 0x00000218590A0520>

>>> t1 = Transaction('A', 'B', 'same')
>>> t2 = Transaction('A', 'B', 'same')
>>> t1 == t2
False

>>> t3 = t1
>>> t1 == t3
True

On constate que :

  • Il n'y a pas d'implémentation automatique des méthodes __str__(self) ou __repr__(self).
  • La comparaison par défaut ne se fait pas sur les contenus mais sur les adresses des objets, ce qui n'est pas intuitif (même si c'est le comportement normal en Python).

Depuis Python 2.4, le module collections propose une aide pour créer de tels objets : les named tuples (ou tuples nommés, en français). Le module contient en effet la fonction namedtuples() qui est une factory pour créer des types. Ces types sont des tuples dont les éléments sont nommés. Voici par exemple comment créer un type équivalent au précédent :

from collections import namedtuple

Transaction = namedtuple('Transaction', 'source, destination, content')

C'est beaucoup plus compact et que les trois points cités précédemment sont réglés :

>>> transaction = Transaction('A', 'B', 'hello there!')
>>> print(transaction)
Transaction(source='A', destination='B', content='hello there!')
>>> t1 = Transaction('A', 'B', 'same')
>>> t2 = Transaction('A', 'B', 'same')
>>> t1 == t2
True

Les named tuples ne sont pas parfaits non plus, et c'est pour ça que les data classes ont été imaginées. La PEP557 l'explique en détails. Le défaut le plus criant est peut-être que leur typage est faible et que comparer deux named tuples revient à comparer leurs contenus mais pas leurs types :

from collections import namedtuple

Transaction = namedtuple('Transaction', 'source, destination, content')
Person = namedtuple('Person', 'name, firstname, city')

transaction = Transaction('Pierre-Marie', 'Carole', 'Nantes')
person = Person('Pierre-Marie', 'Carole', 'Nantes')

print(transaction == person) # --> affiche 'True' alors que le types sont différents !

Un autre défaut est que les named tuples sont forcément immutables, il est donc impossible de modifier leurs champs. Voici un code :

from collections import namedtuple

Person = namedtuple('Person', 'name, firstname, city')
pierre = Person('Gradot', 'Pierre', 'Rennes')
pierre.city = 'Nantes'

Et voici l'erreur qu'il génère :

Traceback (most recent call last):
  File "C:/Users/z19100018/Desktop/temp/temp.py", line 4, in <module>
    pierre.city = 'Nantes'
AttributeError: can't set attribute

@dataclass en action !

Voyons ce que @dataclass nous permet de faire. Créeons des classes avec cette annotation :

from dataclasses import dataclass

@dataclass(frozen=True)
class Transaction:
    source: str
    destination: str
    content: str

@dataclass(frozen=False)
class Person:
    name: str
    firstname: str
    city: str

Et utilisons-les :

>>> transaction = Transaction('A', 'B', 'hello there!')
>>> print(transaction)
Transaction(source='A', destination='B', content='hello there!')
>>> t1 = Transaction('A', 'B', 'same')
>>> t2 = Transaction('A', 'B', 'same')
>>> t1 == t2
True

On retrouve les avantages de named tuples mais on gomme aussi son gros inconvénient :

>>> transaction = Transaction('Pierre-Marie', 'Carole', 'Nantes')
>>> person = Person('Pierre-Marie', 'Carole', 'Nantes')
>>> transaction == person
False # --> c'est beaucoup mieux !

Vous constatez qu'on peut préciser au décorateur, grâce à son paramètre frozen si les instance sont mutables ou pas :

>>> transaction = Transaction('A', 'B', 'hello there!')
>>> transaction.message = 'another message'
Traceback (most recent call last):
  File "<pyshell#19>", line 1, in <module>
    transaction.message = 'another message'
  File "<string>", line 3, in __setattr__
dataclasses.FrozenInstanceError: cannot assign to field 'message'

>>> pierre = Person('Gradot', 'Pierre', 'Rennes')
>>> print(pierre)
Person(name='Gradot', firstname='Pierre', city='Rennes')
>>> pierre.city = 'Nantes'
>>> print(pierre)
Person(name='Gradot', firstname='Pierre', city='Nantes')

Un dernier point avantage qui m'a convaincu personnellement d'abandonner les named tuples au profit des data classes est qu'on retrouve la souplesse d'une classe pour ajouter des méthodes. J'ai effet commencé cet article en disant que ce genre de classe n'avait pas pour vocation d'apporter du comportement, mais on finit souvent par avoir envie (ou besoin !) d'en rajouter.

@dataclass(frozen=True)
class Transaction:
    source: str
    destination: str
    content: str

def is_valid(self):
    return self.source != "" and self.destination != "" \
        and self.source != self.destination

t1 = Transaction('A', 'B', 'this is fine')
t1.is_valid() # --> renvoie True

Conclusion

Les data classes sont une nouvelle alternative pour créer facilement des classes dont le but premier est de stocker des données. Elles n'ont pas vocation à remplacer les named tuples mais dans de nombreuses situations elles peuvent s'avérer bien plus souples et fiables.

Une data class est une classe sur laquelle on applique le décorateur @dataclass. Vous spécifiez les attributs de votre classe et le décorateur génère pour vous plusieurs méthodes comme :

  • un constructeur pour initialiser tous ces champs
  • une implémentation élégante de __repr(self)__
  • des méthodes de comparaisons

Pierre

Que la vie de Pierre, expert embarqué Younup, serait terne sans les variadic templates et les fold expressions de C++17. Heureusement pour lui, Python a tué l'éternel débat sur l’emplacement de l’accolade : "alors, en fin de la ligne courante ou en début de la ligne suivante ?"

Homme de terrain, il est aussi à l’aise au guidon de son VTT à sillonner les chemins de forêt, dans une salle de concert de black metal ou les mains dans les soudures de sa carte électronique quand il doit déboguer du code (bon ça, il aime moins quand même !)

Son vœu pieux ? Il hésite encore... Faire disparaitre le C embarqué au profit du C++ embarqué ? Ou stopper la génération sans fin d'entropie de son bureau ?

Retours aux publications