Modéliser une donnée en Python avec le namedtuple
Par Dad 3.0, 11/04/2023
Niveau : Intermediaire
Une difficulté pour écrire un bon programme est d'organiser correctement les données. En effet, une donnée est en générale complexe, j’entends pas là qu’elle est représentée par plusieurs données primitives. Ce n’est alors pas la donnée en elle-même qui nous intéressera mais un de ses composants.
Liste, n-uplet (tuple) ou dictionnaire sont les premières représentations auxquelles on pense surtout lorsqu’on débute en programmation. Puis, lorsqu’on a appris l’objet, on bascule vers les classes afin de simplifier la syntaxe.
En Python, nous avons d’autres structures de données et cet article va vous présenter le namedtuple ou n-uplets avec des champs nommés.
Ce dont vous aurez besoin…
Vous devez connaitre les tuples (N-uplets) et la syntaxe de l'accès à des attributs d'un objet.
Ce que vous apprendrez dans cet article…
- Vous découvrirez le type namedtuple.
- Quelles sont ses caractéristiques.
- Les cas d'utilisation.
- Ses méthodes spécifiques.
Une première donnée simple
Commençons simplement par représenter un point. Un point, c'est une coordonnée soit 2 ou 3 valeurs en fonction de si on est sur une surface ou un volume. Représenté par un tuple, on a :
point2d = (x, y)
point3d = (x, y, z)
Utiliser un n-uplet est cohérent mais peu pratique. En effet, pour accéder à chaque valeur, il faut utiliser la position de la valeur au sein du n-uplet. Avec ce modèle, il y a donc un risque d’erreurs et une limite à l’évolution de cette donnée.
Lorsqu’un développeur utilise un langage Objet, le réflexe est de se trouver vers les objets et de définir une classe. Ainsi, la syntaxe d’accès aux attributs limitera les risques d’erreur et l’encapsulation permettra de faire évoluer le modèle si besoin.
Cependant, c’est souvent un mauvais choix car ces objets ne le sont pas d’un point de vue Programmation Orientée Objet (POO). Selon les principes de la POO, un objet doit avoir certes des données mais aussi des comportements. On se retrouve souvent dans ces cas là à avoir des classes sans comportement.
En Python, nous allons pouvoir utiliser un type de données intermédiaire, plus adapté : les namedtuple ou n-uplets avec des champs nommés.
Représentation avec un namedtuple
Le type namedtuple est proposé par la bibliothèque collections qui fait parti de la distribution standard de Python. Il faudra donc l’importer avant de l’utiliser.
from collections import namedtuple
Il s’agit en fait d’une fonction qui va nous permettre de créer ces fameux namedtuple qui en fait n’existeront jamais dans votre programme. Pour comprendre, voyons comment s’utilise cette fonction avec notre exemple.
L’usage le plus simple de cette fonction va être de fournir deux arguments : le nom du type que nous voulons créer sous forme de chaine de caractères et une séquence de chaines de caractères représentant le noms d’attributs. La fonction retournera un type qu’il est d’usage de référencer dans une variable du même nom.
Point2d = namedtuple("Point2d", ('x', 'y'))
Point3d = namedtuple("Point2d", ('x', 'y', 'z'))
Nous venons donc de créer les types Point2d et Point3d qui sont des callables qui prennent respectivement 2 et 3 arguments.
p2d = Point2d(12, 45)
p3d = Point3d(12, 45, -16)
Les objets retourné sont de type Point2d et Point3d mais aussi de type tuple. Nous allons donc pouvoir l’utiliser comme un n-uplet en accédant à ses éléments par indice (lignes 1 et 2) ou par déballage (unpacking, ligne 4).
x = p2d[0]
y = p2d[1]
x, y, z = p3d
Mais nous pouvons aussi avec ce type utiliser la syntaxe objet d’accès aux attributs.
x = p2d.x
y = p2d.y
La fonction namedtuple permet donc de créer des types afin de créer des objets qui seront également de type tuple, objets pour lesquels on accède aux éléments par le nom de leur champ. Ces objets restent des n-uplets et sont donc immuables (vous ne pouvez pas remplacer une valeur même par affectation de champ).
Nous avons donc un intermédiaire entre le n-uplet et l’objet. Les programmes avec des données métier sous forme de namedtuples seront donc plus simples à écrire et lire et ne poseront pas de problème d’évolution comme avec l’usage de listes et de dictionnaires et respectent le paradigme Objet.
Gérer les paramètres optionnels
La création de certaines données doit prendre en charge la notion de paramètres optionnels. Après tout, dans notre exemple, nous n'avons pas besoin de deux types. Un point 2D, c'est un point 3D avec la valeur de l'axe z à 0.
Le namedtupe le permet en prenant en argument (optionnel) une séquence de valeurs par défaut. Dans notre cas, ce sera :
Point = namedtuple("Point", ('x', 'y', 'z'),
defaults=(0,))
Comme les paramètres optionnels doivent suivre les paramètres positionnels (sans valeur par défaut), les éléments de cet itérable seront affectés aux derniers éléments de la liste d’attributs, dans l’ordre. Dans notre cas, 0 est affecté à z.
À titre d’exemple, si nous avions une quatrième valeur qui serait, disons, le symbole à utiliser dans un graphique et par défaut l'étoile, le code serait :
Point = namedtuple("Point", ('x', 'y', 'z', 'shape'),
defaults=(0, '*',))
À nouveau, ces valeurs sont optionnelles, elles viennent compléter si nécessaire les attributs manquants lors de la création du namedtuple.
Des méthodes spécifiques
Le namedtuple implémente 3 méthodes dédiées à sa manipulation. Celles-ci sont préfixées par un tiret bas (underscore) comme les méthodes non-publiques. D’après la documentation c'est pour éviter un conflit de noms.
Pour commencer, la méthode namedtuple._make(iterable) est une méthode de classe qui permet de créer un objet à partir d’un itérable.
point_data = [45, 67, 3]
p = Point._make(point_data)
Elle est destinée à être utilisée pour la création de namedtuples lors de la lecture de données tel que les fichiers csv ou les retours de bases de données.
La méthode namedtuple._asdict() retourne simplement le contenu de l’objet sous forme d’un dictionnaire où les clefs seront les noms des champs associés à leurs valeurs respectives.
>>> p = Point(45, 67, 3)
>>> p._asdict()
{'x': 45, 'y': 67, 'z': 3}
Depuis Python 3.8, le dictionnaire natif de Python garanti la conservation de l’ordre d’ajout des éléments ce que nous retrouvons donc ici. Auparavant, cette méthode retourne un OrderedDict.
Enfin, namedtuple._replace(**kwargs) permet de remplacer la valeur d’un ou de plusieurs champs. Le namedtuple est un n-uplet, donc, immuable. Cette méthode va donc évidemment retourner une nouvelle instance avec les données à jour.
Pour illustrer l'intérêt, je vais prendre un autre exemple dérivée d'un point. Soit une particule qui sera un point qui se déplace sur l'écran. Elle possède 4 données : x et y pour les coordonnées et vx et vy pour la vitesse de déplacement sur chaque axe.
Nous allons pouvoir facilement créer la donnée correspondant à la données suivante.
>>> Particle = namedtuple("Particle", ('x', 'y', 'vx', 'vz'))
>>> p = Particle(45, 67, 3, 4)
>>> p._replace(x=p.x + p.vx, y=p.y + p.vy)
Particle(x=48, y=71, vx=3, vy=4)
>>> print(p)
Particle(x=45, y=67, vx=3, vy=4)
Et des attributs spécifiques…
Le type namedtuple possède également deux attributs de classe utiles pour l’introspection. Pour commencer, namedtuple._fields retourne un n-uplet des noms des champs sous forme de chaines de caractères. Ensuite, namedtuple._field_defaults retourne un dictionnaire où les clefs sont les noms des champs ayant une valeur par défaut et la valeur, la valeur par défaut qui leur est associée. Leurs appels possibles sont présentés ci-dessous.
>>> Particle = namedtuple("Particle", ("x", "y", "vx", "vy"),
defaults=(2, 1,))
>>> p = Particle(10,25)
>>> p._fields
('x', 'y', 'vx', 'vy')
>>> p._field_defaults
{'vx': 2, 'vy': 1}
>>> Particle._fields
('x', 'y', 'vx', 'vy')
>>> Particle._field_defaults
{'vx': 2, 'vy': 1}
Un aspect intéressant de l’attribut _fields est qu’il peut permettre de construire d’autres namedtuples plus complexes sur la base de namedtuples plus simples. Par exemple :
>>> Point = namedtuple("Point", ("x", "y"))
>>> VectorialSpeed = namedtuple('VectorialSpeed', ("vx", "vy"))
>>> Particle = namedtuple("Particle",
Point._fields + VectorialSpeed._fields)
>>> Particle._fields
('x', 'y', 'vx', 'vy')
Les limites des namedtuple
Un namedtuple reste une séquence, un simple transporteur de données. Lorsque vous instanciez un namedtuple, il n’y a aucune validation ou transformation des données comme vous pourriez en avoir dans le constructeur d'une classe. Il n’y a pas non plus de possibilité de donner les capacités spéciales (avec les méthodes spéciales comme les comparaisons) à ces types.
Et ce n’est pas grave… Le namedtuple n’est pas destiné à ça, c’est bien un type destiné à transporter des données.
Mais attention, ce n'est pas non plus un tuple… L'intérêt d'un tuple, c'est la performance d'accès de part son immuabilité. Or, le namedtuple ajoute une couche pour accéder aux données ce qui le rends moins performant qu'un tuple.
L’évolution vers l’objet
L'intérêt du namedtuple est donc d'avoir une donnée pour laquelle on accède à ses champs avec une syntaxe attributs (point.x par exemple). Utilisez le donc pour cela.
Mais les besoins des programmes évoluent. Il est possible qu’à un moment, vous arriviez à la limite du namedtuple et qu’il sera nécessaire de passer à l’Objet en définissant une classe.
Et bien vous pouvez voir qu’il n’y aura pas trop de conséquences sur le code existant. Il suffit de remplacer la définition du type par une classe. Grâce au Duck Typing, le code qui utilise le namedtuple n’a besoin d’aucune modification. Tous les accès aux attributs seront identique et vous pourrez ajouter les fonctionnalités de l’Objet. Évidemment, si vous utilisiez les méthodes spécifiques des namedtuple, il vous suffira de les implémenter.
En conclusion
Le namedtuple est un type de donnée adaptée à la représentation de donnée complexe. Il est suffisamment souple pour vous permettre de représenter votre information et de l’utiliser avec la syntaxe d’accès aux attributs. Le fait que ce soit un n-uplet, donc immuable, est justement bien adapté à la notion transport de données.
Si votre besoin évolue et que vous avez besoin de vous tourner vers l’Objet, la syntaxe et le duck-typing permet de faire l’évolution avec le minimum d’impact sur le code existant.
