Les data classes de Python 3.7

pgradot

Pierre Gradot

Posted on March 26, 2020

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 !
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

@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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 !
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode




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

Merci d'avoir lu cet article !
Il a été posté initialement sur notre blog : https://www.younup.fr/blog/

💖 💪 🙅 🚩
pgradot
Pierre Gradot

Posted on March 26, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related