Les data classes de Python 3.7
Tags: python, french
Published: 2020-03-26 15:24:16

Une des fonctionnalités principales ajoutées en [Python 3.7](https://www.python.org/downloads/release/python-370/) est le module `dataclasses`, décrit dans la [PEP557](https://www.python.org/dev/peps/pep-0557/), et qui contient notamment l'annotation `@dataclass` pour créer des _data classes_.

[L'_abstract_ de cette PEP](https://www.python.org/dev/peps/pep-0557/#abstract) 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](https://dev-to-uploads.s3.amazonaws.com/i/9evc8z47s7k0dpanpmbk.png)

## 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](https://www.python.org/dev/peps/pep-0557/#why-not-just-use-namedtuple). 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](https://docs.python.org/3/library/dataclasses.html#frozen-instances) :

    >>> 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

______

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