Définition
Un programme manipule rarement une valeur isolée. Un compte bancaire, ce n'est pas un nombre : c'est un titulaire, un solde, une devise. Rangées dans des variables séparées, elles doivent voyager ensemble dans chaque fonction qui touche au compte, et rien n'empêche d'y mélanger le solde d'un client et le nom d'un autre.
L'attribut règle cet ennui en accrochant la donnée à l'objet. C'est un nom que l'on atteint en écrivant un point suivi de ce nom, et qui appartient à un objet précis : là où une variable vit seule dans son espace de noms, un attribut suit son objet partout.
class Compte:
def __init__(self, titulaire):
self.titulaire = titulaire # deux attributs posés
self.solde = 0 # à la création de l'objet
compte = Compte("Ada")
print(compte.titulaire) # Ada : lecture d'un attribut
compte.solde = 250 # écriture dans l'attributLe point déclenche une recherche, pas une simple lecture. Python cherche le nom sur l'objet, puis sur sa classe, puis sur les classes dont elle hérite. C'est cette remontée qui rend les méthodes, écrites une seule fois sur la classe, disponibles sur chaque objet.
Quand le nom ne se trouve nulle part, Python lève une AttributeError qui indique l'attribut manquant et le type réel de l'objet. Ce dernier point est souvent le plus utile : il révèle qu'on ne tenait pas l'objet qu'on croyait.
Un attribut, une méthode, et ce qui les sépare
Les deux s'écrivent après un point, ce qui brouille la frontière plus souvent qu'on ne le croit. Il n'existe pourtant qu'une catégorie : une méthode est un attribut dont la valeur se laisse appeler. Tout ce qui suit un point est un attribut, les fonctions comprises.
Le tableau ci-dessous met côte à côte quatre écritures presque identiques à l'oeil : ce qui les sépare n'est pas le point, ce sont les parenthèses.
| Écriture | Ce que Python en fait |
|---|---|
compte.solde | Renvoie la valeur rangée sous ce nom |
compte.crediter | Renvoie la fonction elle-même, sans l'exécuter |
compte.crediter(50) | Exécute la fonction, l'objet en premier paramètre |
compte.solde() | Tente d'appeler un nombre, et lève une TypeError |
Oublier les parenthèses ne plante pas sur le coup, et c'est ce qui rend l'erreur coûteuse : une fonction est une valeur ordinaire, que Python range sans protester. Le programme continue avec une fonction là où il attendait un résultat, et la panne se déclare bien plus loin, en comparaison jamais vraie ou en affichage commençant par <bound method.
Attribut d'instance et attribut de classe
Reste à savoir où écrire un attribut : les deux emplacements possibles ne donnent pas le même résultat.
Un attribut posé dans __init__ à travers self appartient à une instance et à elle seule : chaque objet reçoit son exemplaire, et le modifier ne regarde que lui.
Un attribut écrit dans le corps de la classe appartient à la classe, donc à toutes ses instances à la fois : il n'en existe qu'un seul exemplaire.
class Compte:
devise = "EUR" # attribut de classe, partagé : voulu
operations = [] # attribut de classe, partagé : piège
def __init__(self, titulaire):
self.titulaire = titulaire # attribut d'instance, un par objetSur devise, le partage est recherché. Sur operations, il est accidentel : la liste étant mutable, tous les comptes y ajoutent leurs opérations et les historiques se mélangent, avec un symptôme reconnaissable, un objet tout neuf qui arrive déjà rempli.
Un attribut de classe mutable, liste ou dictionnaire, ne se voit pas tant qu'un seul objet existe : le défaut apparaît quand plusieurs coexistent, donc en production. La correction tient en une ligne, créer la structure dans __init__.
L'écriture, elle, ne remonte jamais vers la classe : compte.devise = "USD" crée un attribut d'instance qui masque celui de la classe, sans rien changer pour les autres comptes. Retenez l'asymétrie, la lecture peut venir de la classe, l'écriture atterrit toujours sur l'instance.
Les manipuler quand le nom varie
Le point exige un nom écrit en dur, décidé au moment où vous tapez le code. Or il arrive parfois d'un formulaire ou d'un fichier de configuration, et n'est connu qu'à l'exécution. Trois fonctions prennent alors le relais : getattr pour lire, setattr pour écrire, hasattr pour vérifier la présence.
champ = "solde"
getattr(compte, champ) # 250, comme compte.solde
getattr(compte, "taux", 0) # 0 : valeur de repli, aucune erreur
setattr(compte, champ, 300) # équivaut à compte.solde = 300
hasattr(compte, "titulaire") # TrueLe troisième argument de getattr mérite une mention : il fournit une valeur de repli et évite l'exception au lieu de la rattraper, ce qui rend le code lisible sur les champs facultatifs.
Pour inspecter un objet dont on ignore la forme, vars(compte) renvoie le dictionnaire de ses attributs d'instance, et dir(compte) la liste complète des noms accessibles, héritage compris. Dans le REPL, ils vont plus vite qu'une recherche dans la documentation.
Questions fréquentes
Peut-on ajouter un attribut après la création de l'objet ?
Oui, à tout moment, et c'est ce qui rend la faute de frappe silencieuse : compte.slode = 250 ne signale rien, il crée un attribut de plus pendant que le vrai solde reste à zéro. Déclarer les champs attendus avec dataclass, ou fixer __slots__, referme cette porte.
Comment rendre un attribut privé ?
Python n'offre aucune protection véritable, et c'est un choix assumé. Un tiret bas en tête de nom signale une convention que le reste du code est prié de respecter, sans rien interdire. Deux tirets bas compliquent l'accès depuis l'extérieur sans le fermer. Pour contrôler vraiment la lecture et l'écriture, passez par une property.
Pourquoi mon attribut vaut-il autre chose que ce que j'y ai mis ?
Dans la grande majorité des cas, deux objets partagent la même valeur mutable, venue d'un attribut de classe ou d'une valeur par défaut de fonction. Comparez id() sur l'attribut des deux objets : un nombre identique signale une seule valeur, modifiée depuis deux endroits.