Classes et objets
Ce que ce chapitre apporte7 points
- Dire ce qu'une classe apporte par rapport à un dictionnaire, et quand elle n'apporte rien.
- Écrire une classe avec__init__, self et des attributs, puis en créer des objets.
- Ranger les fonctions qui concernent un objet dans sa classe, sous forme de méthodes.
- Donner à un objet un affichage lisible avec__repr__.
- Distinguer une classe, qui est un type, d'un objet, qui en est un exemplaire.
- Prévoir ce qui arrive quand deux étiquettes désignent le même objet.
- Refuser un objet invalide dès sa création, et lire une sous-classe.
L'atelier de l'écurie a rangé chaque pilote dans un dictionnaire, et c'était le bon choix à ce stade. Il a pourtant deux faiblesses qu'on finit toujours par payer. Une faute de frappe dans une clé passe sans le moindre message. Et les fonctions qui travaillent sur un pilote, score, risque, sont posées à côté des données, sans que rien ne dise qu'elles vont ensemble.
Une classe réunit les deux : elle décrit une fois pour toutes ce qu'est un pilote, quelles données il porte et ce qu'on peut lui demander. C'est l'outil de base de la programmation orientée objet, et c'est ce que supposent connu le chapitre sur les paradigmes de programmation et celui sur les patrons de conception.
Ce chapitre en donne l'essentiel, sans chercher à tout couvrir : de quoi lire une classe sans hésiter, en écrire une petite, et savoir quand s'en passer.
Ce que le dictionnaire laisse passer
Voici Han, tel que l'atelier du chapitre 8 le représentait. Il s'est calmé, et l'on veut baisser son indice d'agressivité à 5.
Le score n'a pas bougé, et aucune erreur ne s'est affichée. Écrire dans une clé qui n'existe pas n'est pas une faute pour un dictionnaire : c'est la façon normale d'en ajouter une. Le dictionnaire compte maintenant cinq clés, dont une fausse, et le programme continue avec l'ancienne valeur.
Le second défaut ne se voit pas dans ce bloc, mais il se voit dans un vrai projet. score est une fonction parmi d'autres, dans un fichier parmi d'autres. Rien ne dit qu'elle attend un pilote, ni quelles clés ce pilote doit avoir. Celui qui reprend le code doit le deviner en lisant son corps.
Une classe, un moule
Une classe décrit une sorte d'objet : les données que chacun porte, appelées attributs, et les opérations qu'on peut lui demander, appelées méthodes. Un objet est un exemplaire fabriqué à partir de ce moule ; on dit aussi une instance de la classe.
Trois choses sont nouvelles dans ce bloc.
class Pilote: ouvre la description, et tout ce qui est indenté dessous lui appartient. Par convention, un nom de classe commence par une majuscule et colle ses mots sans souligné : Pilote, CapteurTemperature. C'est ce qui permet de reconnaître d'un coup d'œil Pilote(...), qui fabrique un objet, de score(...), qui appelle une fonction.
Pilote("Dom", 210, 58.4, 8) s'écrit comme un appel de fonction, et c'en est presque un. Python crée un objet vide, appelle __init__ en lui passant cet objet puis les quatre arguments, et rend l'objet une fois rempli. __init__ (deux soulignés de chaque côté) est la méthode d'initialisation : elle ne renvoie rien, son seul rôle est de poser les attributs de départ.
dom.vitesse lit l'attribut vitesse de l'objet dom. Le point remplace les crochets et les guillemets du dictionnaire.
self, l'objet dont on parle
Toute méthode reçoit en premier paramètre l'objet sur lequel elle travaille. Ce paramètre s'appelle self par une convention que tout le monde respecte, et c'est lui qu'on remplit dans __init__ : self.nom = nom veut dire « range la valeur du paramètre nom dans l'attribut nom de l'objet en cours de construction ».
Les deux nom de cette ligne ne sont pas la même chose. À droite, le paramètre, une variable locale qui disparaît à la fin de __init__ comme toute variable locale du chapitre sur les fonctions. À gauche, l'attribut, qui reste attaché à l'objet aussi longtemps que l'objet existe.
On ne passe jamais self soi-même à l'appel : Python s'en charge. C'est pour cela que Pilote se crée avec quatre arguments alors que __init__ a cinq paramètres. L'oublier dans la définition donne une erreur qui paraît absurde tant qu'on n'a pas compris ce décalage.
TypeError: Pilote.__init__() takes 4 positional arguments but 5 were given. On en a donné quatre, et Python en compte cinq : l'objet en construction, passé automatiquement, plus les quatre qu'on a écrits.
self. devant un attributvitesse = vitesse au lieu de self.vitesse = vitesse ne provoque aucune erreur dans __init__ : la ligne crée une variable locale, qui meurt aussitôt. L'erreur arrive plus tard et plus loin, au premier dom.vitesse, sous la forme AttributeError: 'Pilote' object has no attribute 'vitesse'.Devant ce message, le premier réflexe est de relire
__init__ et d'y chercher le self. manquant.
Ce que la classe vérifie, et ce qu'elle ne vérifie pas
Un pilote créé avec un argument en moins est refusé tout de suite, au moment de la création, et le message nomme ce qui manque.
TypeError: Pilote.__init__() missing 1 required positional argument: 'agressivite'. Avec un dictionnaire, la clé oubliée ne se serait manifestée qu'au premier calcul qui en a besoin, peut-être bien plus loin dans le programme.
han.agresivite = 5 crée un nouvel attribut mal orthographié, exactement comme le dictionnaire créait une nouvelle clé : Python autorise à ajouter un attribut à un objet à tout moment. La classe ne supprime donc pas ce risque.Elle le réduit de deux façons. La liste des attributs est écrite à un seul endroit,
__init__, où l'éditeur la lit pour compléter han. à la frappe. Et quand les modifications passent par des méthodes, comme consommer dans l'exercice de fin de chapitre, la faute n'a plus qu'un seul endroit où se cacher : la classe elle-même.
dom = Pilote("Dom", 210, 58.4, 8). Que désigne self pendant l'exécution de __init__ ?
Des méthodes : les fonctions rentrent dans la classe
Une méthode est une fonction définie dans la classe. Elle reçoit l'objet par self, et y lit tout ce dont elle a besoin. score et risque, qui prenaient un pilote en paramètre dans l'atelier, y trouvent naturellement leur place.
Le classement est exactement celui de l'atelier. Ce qui a changé, c'est l'endroit où le code se trouve : quiconque ouvre la classe Pilote voit d'un coup ce qu'un pilote contient et ce qu'on peut lui demander.
La dernière ligne montre ce que Python fait réellement. dom.score() est une écriture abrégée de Pilote.score(dom) : Python cherche score dans la classe de dom, et lui passe dom comme self. C'est aussi ce qui permet d'écrire key=Pilote.score dans le tri : on confie à sorted la fonction rangée dans la classe, sans parenthèses, comme on passait n'importe quelle fonction en paramètre au chapitre 5.
notes.append(20), texte.upper(), carte.items() sont des méthodes des classes list, str et dict. On s'en servait sans savoir qu'on pouvait en écrire.
Dans la classe Pilote, la méthode score est déclarée def score(self):. Comment l'appelle-t-on sur l'objet dom ?
Un objet qui se présente : __repr__
Afficher un objet donne d'abord quelque chose de décevant.
On lit <__main__.Pilote object at 0x...>, suivi d'une adresse en hexadécimal qui change d'une exécution à l'autre. Python sait de quelle classe est l'objet, mais n'a aucune idée de ce qui mérite d'être montré. C'est à la classe de le lui dire, par une méthode au nom réservé : __repr__.
__repr__ renvoie une chaîne, elle ne l'affiche pas : c'est print qui s'en charge. Et elle sert partout où Python doit montrer l'objet, y compris à l'intérieur d'une liste, ce qui est précieux pour déboguer une écurie entière d'un seul print.
Le !r dans l'accolade demande la forme « de programmeur » de la valeur : pour une chaîne, avec ses guillemets. La convention est que __repr__ rende, autant que possible, le code qui recréerait l'objet. Pilote('Dom', 210) se recopie tel quel dans un programme.
Une méthode spéciale porte un nom encadré de deux soulignés, comme __init__ ou __repr__. On ne l'appelle presque jamais soi-même : c'est Python qui l'appelle, à la création pour la première, à l'affichage pour la seconde. En l'écrivant, on dit à Python comment se comporter avec les objets de la classe.
Une classe est un type
Une classe ne se contente pas de ranger du code : elle crée un nouveau type, au même titre que int ou list. type le montre, et isinstance pose la question « cet objet est-il de telle classe ? ».
La dernière ligne referme la boucle ouverte plus haut : "abc".upper() et str.upper("abc") sont le même appel, exactement comme dom.score() et Pilote.score(dom). Les types qu'on emploie depuis le premier chapitre sont des classes écrites par d'autres, et 42 est une instance de int.
isinstance sert surtout à vérifier ce qu'une fonction reçoit. Le décorateur exiger_positifs du chapitre 16 l'employait déjà pour ne comparer que les arguments numériques.
Après a = Pilote("Dom", 210) et b = Pilote("Mia", 176), combien existe-t-il de classes et d'objets ?
Deux étiquettes, un seul objet
Le chapitre 4 a montré qu'une affectation ne copie pas une liste : elle colle une deuxième étiquette sur la même. Un objet se comporte exactement de la même façon, puisque ses attributs se modifient.
Le jumeau mérite qu'on s'y arrête. jumeau == dom vaut False, alors que tous les attributs sont égaux : sans indication contraire, Python considère que deux objets d'une classe qu'on a écrite ne sont égaux que s'ils sont le même objet. Pour qu'il compare les attributs, il faudrait l'écrire dans une autre méthode spéciale, __eq__, que ce chapitre laisse de côté.
La même règle vaut quand on passe un objet à une fonction : la fonction reçoit une étiquette de plus sur l'objet, et tout ce qu'elle modifie se voit dehors, comme pour la liste du chapitre 5.
Le piège de la liste par défaut, en version objet
Un capteur garde ses mesures dans une liste. La tentation est de donner à __init__ une liste vide par défaut, et c'est le piège de la valeur par défaut modifiable, rencontré au chapitre 5, qui revient par la fenêtre.
La liste [] est fabriquée une seule fois, au moment du def, et chaque capteur créé sans liste reçoit une étiquette sur la même. Deux objets distincts partagent alors un attribut, ce qui est bien plus difficile à repérer qu'avec une simple fonction. La parade est celle du chapitre 5 : None par défaut, et une liste neuve créée dans le corps.
classCAPACITE = 50, placée sous class Stock: mais hors de toute méthode, crée un attribut de classe : une seule valeur, partagée par tous les objets, qu'on lit par self.CAPACITE ou Stock.CAPACITE. C'est la bonne place pour une constante qui vaut pour toute la classe.Pour la même raison que ci-dessus, ce n'est jamais la place d'une liste qui devrait appartenir à chaque objet : elle serait partagée par tous.
titulaire = dom, puis titulaire.vitesse = 215. Que vaut dom.vitesse ?
Refuser un objet invalide dès sa création
Rien n'empêche pour l'instant de créer un pilote à -50 km/h. L'erreur se révélerait bien plus tard, dans un classement absurde, loin de la ligne fautive. __init__ est l'endroit idéal pour l'arrêter, puisque tous les objets de la classe passent par lui : on y lève une exception avec raise, exactement comme une fonction refuse une valeur absurde au chapitre 5.
La résistance électrique du chapitre 5 en donne un exemple concret, cette fois sous forme d'objet. Sa valeur en ohms doit être positive, et sa tolérance, l'écart garanti par le fabricant, tient en quelques pourcents.
Trois points font la solidité de cette classe.
Les tests viennent avant les affectations. Un objet invalide n'est jamais construit à moitié : ou bien __init__ va au bout, ou bien l'appel échoue et aucun objet n'existe.
en_serie ne fabrique pas son résultat à la main : elle repasse par Resistance(...). La règle est ainsi vérifiée à un seul endroit, et toute résistance produite par la classe elle-même la respecte aussi.
La boucle du bas intercepte l'exception avec try et except ValueError, la forme vue au chapitre 3 pour redemander une saisie invalide. as erreur donne un nom à l'exception interceptée, et l'afficher rend le message écrit dans le raise, comme au chapitre 5. Un message qui cite la valeur reçue fait gagner beaucoup de temps à celui qui la cherche.
r1.valeur = -5 : Python laisse modifier un attribut depuis l'extérieur. Un souligné en tête de nom, self._valeur, signale par convention un attribut interne, que le reste du programme ne doit pas toucher. Ce n'est qu'une convention, que Python ne fait pas respecter, mais tout programmeur Python la lit comme une consigne.C'est la forme qu'on rencontre dans les classes
Stock et Capteur des parcours qui s'appuient sur celui-ci.
Pourquoi placer les raise au début de __init__, avant toute affectation self.… = … ?
L'héritage, en deux mots
Un capteur de température est un capteur, avec quelque chose en plus : un seuil d'alerte. Plutôt que de recopier la classe Capteur, on écrit une sous-classe qui en hérite tout, et n'ajoute que ce qui lui est propre.
class CapteurTemperature(Capteur): déclare l'héritage : le nom entre parenthèses est la classe parente. Tout ce qui n'est pas redéfini dans la sous-classe, ajouter, moyenne, __repr__, est cherché chez le parent.
La sous-classe redéfinit pourtant __init__, et la nouvelle version remplace celle du parent. Sans la ligne super().__init__(nom, "degC"), le nom, l'unité et la liste des mesures ne seraient jamais posés, et four.ajouter échouerait sur un AttributeError. super() désigne le parent : cette ligne lui délègue sa part du travail avant d'ajouter la sienne.
Dans __repr__, type(self).__name__ donne le nom de la classe réelle de l'objet. Le même code affiche donc CapteurTemperature pour le four, et afficherait Capteur pour un capteur ordinaire.
Le diagramme de classes montre la même structure, avec la notation qu'emploient les chapitres de génie logiciel : la sous-classe sous son parent, et ce qu'elle ajoute.
Capteur pour une classe Salle, simplement parce qu'une salle a elle aussi un nom et des mesures, lie deux notions qui n'ont rien à voir, et chaque modification de l'une ira casser l'autre.Une salle possède des capteurs : elle en garde une liste dans un attribut. Le chapitre sur les patrons de conception revient longuement sur ce choix.
Quand une classe n'apporte rien
Tout ce qui précède pourrait donner l'envie de tout ranger dans des classes. Ce serait une erreur, et une erreur fréquente chez ceux qui viennent de Java ou de C#, où une fonction ne peut pas exister hors d'une classe.
La classe n'a pas d'__init__, pas d'attribut, et sa méthode n'utilise jamais self. Elle oblige à créer un objet qui ne sert à rien avant chaque conversion. La fonction fait la même chose, en moins de lignes et sans cérémonie.
Quelques repères pour trancher.
Une classe se justifie quand des données et les opérations qui les concernent vont ensemble, qu'on crée plusieurs objets du même genre, et surtout qu'il y a une règle à garantir : une résistance positive, une batterie jamais plus chargée que sa capacité.
Une fonction suffit quand il n'y a rien à retenir d'un appel à l'autre : un calcul qui reçoit ses données et rend son résultat.
Un dictionnaire suffit pour des données sans règle, qu'on lit, qu'on transmet et qu'on affiche : une ligne de fichier CSV, une configuration, une réponse reçue en JSON.
Et quand il y a un petit état à garder, mais une seule opération, la fermeture du chapitre 16 fait souvent l'affaire : le chapitre la décrivait déjà comme « un objet à une seule méthode, sans classe ».
Une classe Outils ne contient que des méthodes qui n'utilisent jamais self. Que faut-il en penser ?
À mettre en pratique
La batterie. Écrire une classe Batterie qui représente la batterie d'un appareil embarqué.
Batterie(capacite_mah, charge_mah)refuse par uneValueErrorune capacité nulle ou négative, et une charge hors de l'intervalle [0 ; capacité].pourcentage()renvoie la charge en pourcents de la capacité.consommer(mah)retire la quantité demandée, sans jamais faire descendre la charge sous zéro.__repr__renvoie une chaîne de la formeBatterie(1500/2000 mAh).
Afficher la solution
Trois points valent qu'on s'y arrête.
L'ordre des deux tests de __init__ compte. La capacité est vérifiée d'abord, parce que le second test s'en sert comme borne, et que pourcentage divisera par elle : une capacité nulle ferait tomber le programme sur une division par zéro bien plus loin.
consommer ne renvoie rien : elle modifie l'objet. C'est la différence avec en_serie de la résistance, qui laissait les deux résistances intactes et en rendait une nouvelle. Les deux styles existent ; ce qui compte, c'est que le nom de la méthode dise lequel on a choisi.
Le plancher s'écrit max(self.charge_mah - mah, 0), comme la promotion plancher du chapitre 16 : « jamais moins de zéro » est un maximum. Et comme toute modification de la charge passe par cette méthode, la règle n'est écrite qu'une fois.
Synthèse
- Une classe décrit une sorte d'objet : ses attributs (les données) et ses méthodes (les opérations). Un objet, ou instance, est un exemplaire fabriqué à partir d'elle.
Pilote(...)crée l'objet et appelle__init__, qui pose les attributs de départ avecself.nom = nom.selfest l'objet sur lequel la méthode travaille. Python le passe tout seul :dom.score()équivaut àPilote.score(dom).__repr__renvoie la chaîne qui représente l'objet, de préférence le code qui le recréerait.printet les listes s'en servent.- Une classe est un type, comme
intoulist.isinstance(objet, Classe)vérifie qu'un objet en est une instance. - L'affectation ne copie pas un objet :
b = acolle une deuxième étiquette sur le même. Jamais de liste en valeur par défaut d'__init__. __init__vérifie ce qu'il reçoit avant de construire, et lève uneValueErrorpour refuser un objet invalide.- Une sous-classe hérite de tout ce qu'elle ne redéfinit pas, et appelle
super().__init__(...)pour que son parent fasse sa part. - Une classe dont les méthodes n'utilisent jamais
selfest une fonction déguisée. Fonction, dictionnaire ou classe : on prend l'outil le plus simple qui garantit ce qu'il faut garantir.