Python : les dataclass pour des données avec un comportement

De nombreux programmes définissent des classes pour représenter des données. En Python, pour respecter les principes de la programmation orientée objet, nous avons les namedtuples que nous avons vu dans un article précédent. Mais ceux-ci ne permettent pas d’ajouter un comportement. Les classes restent donc la meilleur solution mais déclarer correctement un type correspondant à une donnée est très verbeux.

Pour éviter l’aspect laborieux de la déclaration de classe, Python propose le type dataclass. Ce type permet d’économiser du temps d’écriture mais nécessite de connaitre sa configuration pour être correctement utilisé.

Pour illustrer ce type, je vais reprendre le cas des particules qui m’accompagne sur cette série d’articles sur la modélisation de données. Nous allons d’ailleurs voir que ce type est très adapté à ce cas.

Une classe pour représenter une particule

Créer une classe pour représenter une donnée consiste principalement à écrire le code du constructeur en déclarant les paramètres attendus et en affectant les arguments aux attributs du même nom, dans un transporteur de données, il n’y a pas vraiment d’encapsulation.

Pour bien faire les choses, il faut ajouter les méthodes __str__() et __repr__() afin d’avoir d’une part quelque chose de lisible lors de l’affichage de l’objet et d’autre part, une réelle représentation de l’objet.

Ensuite, si nous passons à l’objet, nous attendons également le comportement minimum pour les comparaisons. Par défaut, en Python, le test d’égalité fait un test d’identité (deux objets sont égaux si c’est le même). Il faut donc surcharger la méthode __eq__(other).

Le code minimum pour représenter la donnée sous forme d’objet est alors le suivant.

class Particle:
    def __init__(self, x: float, y: float, vx: float, vy: float, age: float = 0):
        self.x = x
        self.y = y
        self.vx = vx
        self.vy = vy
        self.age = age

    def __str__(self):
        return f"Particle x={self.x} y={self.y} - vx={self.vx}, vy={self.vy} - age={self.age}"

    def __repr__(self):
        return f"Particle({self.x}, {self.y}, {self.vx}, {self.vy}, {self.age})"

    def __eq__(self, other):
        if other.__class__ is not self.__class__:
            return False
        return (self.x, self.y, self.vx, self.vy, self.age) \
            == (other.x, other.y, other.vx, other.vy, other.age)

C’est déjà très verbeux et fastidieux à écrire. Mais on peut souhaiter être plus rigoureux : même si il n’y a pas de réelle encapsulation des données, il peut-être judicieux d’empêcher l’écriture des attributs qui devront donc être accessibles en lecture seule. Le vrai code nécessite donc de basculer ces attributs en non-publique et de rajouter des properties accesseur soit 15 lignes en plus… que je ne vais pas vous reproduire ici.

Vous avez compris l’idée. Écrire tout le code pour un simple transporteur de données peut-être fastidieux. En fait, c’est la raison d’être des namedtuples car pour cet exemple, il sont suffisants. Mais si on reste sur des classes (car on voudra ajouter des méthodes), en Python, on aime bien se faciliter la vie… C’est alors qu’en Python 3.7 sont apparues les Data Classes.

Les Data Classes

Les Data Classes, ou classes de données en français, ont été spécifiées dans la PEP 557 et inclues dans le langage depuis Python 3.7. La PEP définit la notion de Data Class comme « un namedtuple mutable avec des [comportements par] défauts ».

La notion de data class est en fait un décorateur sur une déclaration de classe. Cette classe n’a qu’à se contenter de déclarer les champs qui la concernent. Notre classe précédente s’écrirait donc comme cela :

from dataclasses import dataclass

@dataclass
class Particle:
    x: float
    y: float
    vx: float
    vy: float
    age: int = 0

Et oui, c’est tout. La data class rajoute automatiquement un constructeur qui est exactement celui que nous avons écrit dans le premier code, une méthode __repr__() (similaire à la notre) ainsi que des méthodes de comparaison (__eq__(other) mais aussi __ne__(other), __lt__(other), __le__(other), __gt__(other) et __ge__(other)) qui comparent le tuple de l’ensemble des champs sur le modèle suivant :

def __eq__(self, other):
    if other.__class__ is self.__class__:
        return (self.x, self.y, self.vx, self.vy, self.age) \
            == (other.x, other.y, other.vx, other.vy, other.age)
    return NotImplemented

La méthode __str__() n’est pas créée automatiquement mais son comportement par défaut appelle la méthode __repr__().

L’objectif des data classes est donc d’économiser du temps d’écriture et de maintenance de tout ce code purement utilitaire.

Les data classes restent des classes

Contrairement aux namedtuples, les data classes restent des classes. Vous pouvez donc à loisir les enrichir de méthodes. Une particule pourra donc avoir des comportements comme par exemple :

from dataclasses import dataclass

@dataclass
class Particle:
    ...

    def update_data(self, delay, drag=0.8):
        drag = drag ** delay
        self.vx *= drag
        self.vy *= drag

        self.x += self.vx * delay
        self.y += self.vy * delay

        self.age += delay

    def is_alive(self, max_age=3):
        return self.age <= max_age

Les data classes sont ouvertes à la configuration

Le décorateur dataclass() peut être configuré. Sa signature est en fait la suivante :

def dataclass(*, init=True, repr=True, eq=True, order=False, unsafe_hash=False, frozen=False, match_args=True, kw_only=False, slots=False, weakref_slot=False)

Je ne vais ici vous présenter que les paramètres les plus usuels. Pour les autres, je vous laisse voir la documentation.

Un des paramètres les plus intéressant est frozen. Si nous lui passons l’argument True, les attributs perdent leur accès en écriture. En effet, jusqu’ici, nous n’étions pas tout à fait dans une évolution du namedtuple car cet objet Particle est mutable. Si nous déclarons la classe de cette manière :

@dataclass(frozen=True)
class Particle:
    x: float
    y: float
    vx: float
    vy: float
    age: int = 0

Il ne sera plus possible d’affecter une valeur à un attribut. Nous serons alors dans le cas décrit en fin de présentation de l’implémentation par classe classique… Mais pas tout à fait non plus… Une data class frozen est totalement immuable à l’image des tuples. Il n’est pas possible de modifier un attribut. En conséquence, notre méthode Particle.update_data() déclarée plus haut ne marche plus ! Faites donc attention quand vous déclarez une data class frozen, les objets sont immuables.

Si vous passez l’argument eq=False, il n’y aura pas de génération de méthode __eq__(other). Notez que si vous définissez cette méthode, le paramètre sera ignoré.

C’est exactement la même chose pour repr=False : alors la méthode ne sera pas générée et si vous la déclarez, le paramètre est ignoré.

Pour le paramètre order, le comportement est légèrement différent. Avec order=True, les méthodes __lt__, __le__, __gt__, et __ge__ seront générées et compareront un n-uplet de tous les champs. Mais si, alors que ce paramètre est à True, vous déclarez une de ces méthodes, une ValueError sera levée.

Enfin, le paramètre init=True entraine la création automatique de l’initialiseur. Bien entendu, vous pouvez en déclarer un (le paramètre sera alors ignoré) mais à mon avis, autant alors rester sur des classes. N’hésitez pas à donner votre avis sur point en commentaire, c’est un sujet à discussion.

Quelques réglages plus fins

La partie précédente décrit ce dont vous aurez besoin pour les cas les plus usuels. Dans certaines situations, il vous sera nécessaire de procéder à des réglages plus fins. Le module propose un ensemble d’outils dont je vous propose ici un petit aperçu.

Pour cela, ajoutons une nouvelle classe Explosion. L’explosion est l’épicentre des particules. En tant qu’objet, il servira aussi de conteneur.

L’implémentation sous forme de classe peut-être la suivante :

class Explosion:
    def __init__(self, x: float, y: float, speed: int = 300):
        self.x = x
        self.y = y
        self.speed = speed

        self.particles = []

        self._explode()

    def _explode(self):
        for _ in range(100):
            angle = random.uniform(0, 2 * math.pi)
            radius = random.uniform(0, 1) ** 0.5

            # convert angle and distance from the explosion point into x and y velocity for the particle
            vx = self.speed * radius * math.sin(angle)
            vy = self.speed * radius * math.cos(angle)

            # add the particle's position, velocity and age to the array
            self.particles.append(Particle(self.x, self.y, vx, vy))

Si vous vous référerez au code d’origine, vous voyez que la méthode Explosion._explode(self) est la transposition de la fonction qui génère les particules. Lorsque nous devons avoir une nouvelle explosion, nous créons une nouvelle instance de cette classe qui s’occupera de tout.

Vous voyez que cette classe est un bon candidat pour être transposé en une dataclass. Cependant, il y a quelques points qui ne vont pas. Pour commencer, cette classe possède un attribut qui doit être créé automatiquement : Explosion.particles doit toujours exister et contenir une liste. Et ce ne doit pas être un paramètre du constructeur.

Paramétrer les champs

Vous disposez de la possibilité de paramétrer les champs. Nous n’allons plus leur affecter un type mais le retour de la fonction dataclass.field(). Celle-ci contient beaucoup de paramètres, je n’en aborderai que quelques uns. Pour plus de détails, vous pouvez consulter la documentation.

Les paramètres de la fonction dataclass.field() qui vont m’intéresser sont les suivants :

  • init=True : Si True, le champ est inclu dans les paramètres qui génèrent le __init__(). Nous lui passerons l’argument False pour qu’il n’apparaisse pas.
  • repr=True : Si True, le champ est inclu dans la chaine retournée par __repr__(). Son état va en général de paire avec le paramètre précédent.
  • compare=True : Si True, le champ est inclu dans les méthodes de comparaison (égalité et autres). Dans notre cas, je vais considérer qu’il doit être à False.

Voici donc la déclaration de la dataclass :

from dataclasses import dataclass, field

@dataclass
class Explosion:
    x: float
    y: float
    speed: int = 300
    particles: list[Particle] = field(init=False, repr=False, compare=False)

Pour résumer, cette expression ligne 8 permet de déclarer un attribut particles qui n’est pas présent dans les paramètres du constructeur, ne sera pas affiché dans la représentation de l’objet et ne sera pas utilisé pour comparer l’objet.

Mais attention, si grâce à cette expression les IDEs connaitront cet attribut, il ne sera pas créé. Il est nécessaire de le faire manuellement. Et cette création doit se faire à l’initialisation de l’objet… phase qui est automatisée dans le cas des data classes. Pour palier à ce problème, le type dataclass y dédie une méthode.

Les traitement post-init

La classe Explosion a deux actions à réaliser dans son initialiseur. Pour commencer, puisque le champs Explosion.particles n’existe plus en tant que paramètre, il faut du code pour le créer. Ensuite, il faut remplir cette collection de particules. Ce remplissage peut être réalisé comme pour la classe classique avec une méthode privée mais il faut l’appeler.

Pour cela, le type dataclass dispose d’une méthode appelée __post_init__(). Le code généré du __init__() appellera automatiquement cette méthode, si elle existe, à la fin de son exécution. Nous allons donc ajouter à notre dataclass le code suivant

def __post_init__(self):
    self.particles = []
    self._explode()

Et nous avons maintenant un comportement identique à l’implémentation avec une classe classique. Attention cependant, du fait de la seconde ligne (affectation à un attribut), ce code n’est pas compatible avec l’option frozen=True qui empêche toute modification de l’attribut (même celle-là).

En conclusion

Les data class sont donc un type de classes qui permettent de déclarer une classe de manière moins verbeuse. Mais elles sont destinées à définir des types correspondant plus à des données ayant des comportements qu’à des objets. Elles doivent donc être vues comme des namedtuples améliorés que comme des classes simplifiées. Dans tous les cas, n’hésitez pas à considérer leur usage pour simplifier le code.

Si vous avez aimé ce post, n’hésitez pas à laisser un commentaire ci-dessous ou sur la page Facebook 😉

À propos de... Darko Stankovski

iT guy, photographe et papa 3.0, je vous fais partager mon expérience et découvertes dans ces domaines. Vous pouvez me suivre sur les liens ci-dessous.

Vous aimerez aussi...

Laisser un commentaire

En naviguant sur Dad 3.0, vous acceptez l’utilisation de cookies pour une navigation optimale et nous permettre de réaliser des statistiques de visites. Plus d'informations

Le blog Dad 3.0 utilise les cookies pour vous permettre une navigation optimale et nous permettre de réaliser des statistiques de visite. Dad 3.0 affichant des publicités, celles-si utilisent également des cookies pour un ciblage publicitaire. En continuant la navigation sur Dad 3.0, vous acceptez le dépôt et la lecture de cookies.

Fermer