Protocoles et méthodes spéciales#
Le modèle de données Python#
L’une des caractéristiques les plus élégantes de Python est son modèle de données unifié (data model). En Python, absolument tout est un objet — les entiers, les chaînes de caractères, les fonctions, les classes elles-mêmes, les modules, et même None. Chaque objet possède un type, un identifiant unique, et un ensemble de méthodes qui définissent son comportement.
Le modèle de données Python est fondé sur l’idée que le langage définit des protocoles — des ensembles de méthodes spéciales que les objets peuvent implémenter pour s’intégrer nativement dans le langage. Ces méthodes spéciales sont reconnaissables à leur syntaxe : elles commencent et se terminent par deux underscores (__init__, __len__, __add__…). La communauté Python les appelle affectueusement les dunder methods (de l’anglais double underscore).
Définition 16 (Méthode spéciale (dunder method))
Une méthode spéciale est une méthode dont le nom est encadré par deux underscores (ex. __len__). Elle n’est généralement pas appelée directement par le code applicatif, mais par le runtime Python en réponse à certaines opérations syntaxiques ou built-in. Par exemple, len(obj) appelle obj.__len__(), obj[i] appelle obj.__getitem__(i), et obj + autre appelle obj.__add__(autre). Ce mécanisme permet à vos classes de s’intégrer parfaitement dans le langage.
La beauté de ce système est qu’il rend le langage extensible de façon cohérente. Quand vous écrivez for elem in ma_collection:, Python appelle iter(ma_collection), qui appelle ma_collection.__iter__(). Si votre classe implémente __iter__, elle devient automatiquement compatible avec les boucles for, les expressions génératrices, la fonction list(), sorted(), et toutes les fonctions de la bibliothèque standard qui travaillent avec des itérables. Un seul protocole, une intégration complète.
Protocole des conteneurs#
Le protocole des conteneurs permet à vos classes de se comporter comme des collections — listes, dictionnaires, ensembles, ou tout autre type de conteneur.
Définition 17 (Protocole des conteneurs)
Le protocole des conteneurs est défini par les méthodes spéciales suivantes :
__len__(self): appelé parlen(obj), doit retourner un entier ≥ 0.__getitem__(self, clé): appelé parobj[clé], permet l’accès par indice ou clé.__setitem__(self, clé, valeur): appelé parobj[clé] = valeur.__delitem__(self, clé): appelé pardel obj[clé].__contains__(self, élément): appelé parélément in obj. Si absent, Python itère sur l’objet.__iter__(self): appelé pariter(obj)et les bouclesfor. Doit retourner un itérateur.__reversed__(self): appelé parreversed(obj).
Construisons un exemple : une matrice creuse (sparse matrix) qui ne stocke que les valeurs non nulles.
class MatriceCreuse:
"""Matrice 2D qui ne stocke que les valeurs non nulles."""
def __init__(self, lignes: int, colonnes: int) -> None:
self.lignes = lignes
self.colonnes = colonnes
self._donnees: dict[tuple[int, int], float] = {}
def _verifier_indices(self, ligne: int, col: int) -> None:
if not (0 <= ligne < self.lignes and 0 <= col < self.colonnes):
raise IndexError(
f"Indices ({ligne}, {col}) hors de la matrice "
f"{self.lignes}×{self.colonnes}"
)
def __len__(self) -> int:
"""Nombre total d'éléments dans la matrice."""
return self.lignes * self.colonnes
def __getitem__(self, clé: tuple[int, int]) -> float:
ligne, col = clé
self._verifier_indices(ligne, col)
return self._donnees.get((ligne, col), 0.0)
def __setitem__(self, clé: tuple[int, int], valeur: float) -> None:
ligne, col = clé
self._verifier_indices(ligne, col)
if valeur == 0.0:
self._donnees.pop((ligne, col), None)
else:
self._donnees[ligne, col] = valeur
def __delitem__(self, clé: tuple[int, int]) -> None:
ligne, col = clé
self._verifier_indices(ligne, col)
self._donnees.pop((ligne, col), None)
def __contains__(self, clé: tuple[int, int]) -> bool:
"""Vérifie si une cellule contient une valeur non nulle."""
return clé in self._donnees
def __iter__(self):
"""Itère sur les positions (ligne, colonne, valeur) non nulles."""
for (l, c), v in self._donnees.items():
yield l, c, v
def densite(self) -> float:
"""Proportion de valeurs non nulles."""
return len(self._donnees) / len(self) if len(self) > 0 else 0.0
def __repr__(self) -> str:
return (f"MatriceCreuse({self.lignes}×{self.colonnes}, "
f"{len(self._donnees)} valeurs non nulles)")
m = MatriceCreuse(4, 4)
m[0, 0] = 1.0
m[1, 2] = 3.5
m[3, 3] = -2.0
m[2, 1] = 0.0 # Valeur nulle → non stockée
print(f"m[0, 0] = {m[0, 0]}")
print(f"m[2, 2] = {m[2, 2]}") # Non stockée → retourne 0.0
print(f"len(m) = {len(m)}")
print(f"(1, 2) in m : {(1, 2) in m}")
print(f"(2, 2) in m : {(2, 2) in m}")
print(f"Densité : {m.densite():.1%}")
print(f"\nValeurs non nulles :")
for ligne, col, valeur in m:
print(f" [{ligne}, {col}] = {valeur}")
print(repr(m))
m[0, 0] = 1.0
m[2, 2] = 0.0
len(m) = 16
(1, 2) in m : True
(2, 2) in m : False
Densité : 18.8%
Valeurs non nulles :
[0, 0] = 1.0
[1, 2] = 3.5
[3, 3] = -2.0
MatriceCreuse(4×4, 3 valeurs non nulles)
Protocole numérique#
Le protocole numérique permet à vos objets de se comporter comme des nombres, avec les opérateurs arithmétiques habituels.
Définition 18 (Protocole numérique)
Les méthodes spéciales arithmétiques se déclinent en trois catégories :
Opérateurs normaux :
__add__(+),__sub__(-),__mul__(*),__truediv__(/),__floordiv__(//),__mod__(%),__pow__(**).Opérateurs réfléchis (préfixe
r) :__radd__,__rsub__… appelés lorsque l’opérande gauche ne sait pas gérer l’opération (retourneNotImplemented).Opérateurs en place (préfixe
i) :__iadd__,__isub__… appelés par+=,-=… Ils modifient l’objet en place et retournentself.
from fractions import Fraction as _Fraction # Pour comparaison
class Fraction:
"""Fraction irréductible p/q."""
def __init__(self, numerateur: int, denominateur: int = 1) -> None:
if denominateur == 0:
raise ZeroDivisionError("Le dénominateur ne peut pas être zéro")
from math import gcd
pgcd = gcd(abs(numerateur), abs(denominateur))
signe = -1 if denominateur < 0 else 1
self._num = signe * numerateur // pgcd
self._den = signe * denominateur // pgcd
@property
def numerateur(self) -> int:
return self._num
@property
def denominateur(self) -> int:
return self._den
def __repr__(self) -> str:
if self._den == 1:
return str(self._num)
return f"{self._num}/{self._den}"
def __add__(self, other: "Fraction | int") -> "Fraction":
if isinstance(other, int):
other = Fraction(other)
if not isinstance(other, Fraction):
return NotImplemented
return Fraction(
self._num * other._den + other._num * self._den,
self._den * other._den
)
def __radd__(self, other: "int") -> "Fraction":
"""Appelé quand other + self et other ne sait pas gérer Fraction."""
return self.__add__(other)
def __mul__(self, other: "Fraction | int") -> "Fraction":
if isinstance(other, int):
other = Fraction(other)
if not isinstance(other, Fraction):
return NotImplemented
return Fraction(self._num * other._num, self._den * other._den)
def __rmul__(self, other: "int") -> "Fraction":
return self.__mul__(other)
def __sub__(self, other: "Fraction | int") -> "Fraction":
if isinstance(other, int):
other = Fraction(other)
return self + Fraction(-other._num, other._den)
def __neg__(self) -> "Fraction":
return Fraction(-self._num, self._den)
def __truediv__(self, other: "Fraction | int") -> "Fraction":
if isinstance(other, int):
other = Fraction(other)
if not isinstance(other, Fraction):
return NotImplemented
return Fraction(self._num * other._den, self._den * other._num)
def __eq__(self, other: object) -> bool:
if isinstance(other, int):
return self._num == other and self._den == 1
if isinstance(other, Fraction):
return self._num == other._num and self._den == other._den
return NotImplemented
def __float__(self) -> float:
return self._num / self._den
def __hash__(self) -> int:
return hash((self._num, self._den))
a = Fraction(1, 2)
b = Fraction(1, 3)
print(f"a = {a}, b = {b}")
print(f"a + b = {a + b}")
print(f"a * b = {a * b}")
print(f"a - b = {a - b}")
print(f"a / b = {a / b}")
print(f"2 + a = {2 + a}") # Appelle __radd__
print(f"3 * b = {3 * b}") # Appelle __rmul__
print(f"float(a) = {float(a)}")
a = 1/2, b = 1/3
a + b = 5/6
a * b = 1/6
a - b = 1/6
a / b = 3/2
2 + a = 5/2
3 * b = 1
float(a) = 0.5
Protocole d’appel#
En Python, un objet est appelable (callable) s’il implémente __call__. Cela permet de créer des objets qui se comportent comme des fonctions, tout en conservant un état entre les appels — ce que les fonctions simples ne peuvent pas faire.
Définition 19 (Protocole d’appel)
Un objet est appelable s’il définit la méthode __call__(self, *args, **kwargs). Un objet appelable peut être invoqué avec la syntaxe obj(arguments). La fonction built-in callable(obj) retourne True si l’objet est appelable. Les fonctions, méthodes, classes et générateurs sont tous appelables. Tout objet dont la classe définit __call__ l’est aussi.
class Compteur:
"""Objet appelable qui compte ses invocations."""
def __init__(self, nom: str = "compteur") -> None:
self.nom = nom
self._appels = 0
def __call__(self, *args, **kwargs) -> str:
self._appels += 1
return (f"[{self.nom}] Appel #{self._appels} "
f"avec args={args}, kwargs={kwargs}")
@property
def nb_appels(self) -> int:
return self._appels
def reinitialiser(self) -> None:
self._appels = 0
class Memorisation:
"""Décorateur-objet qui mémorise les résultats d'une fonction."""
def __init__(self, fonction) -> None:
self._fonction = fonction
self._cache: dict = {}
self._appels = 0
self._hits = 0
def __call__(self, *args):
if args in self._cache:
self._hits += 1
return self._cache[args]
self._appels += 1
resultat = self._fonction(*args)
self._cache[args] = resultat
return resultat
def statistiques(self) -> dict:
total = self._appels + self._hits
return {
"calculs": self._appels,
"cache_hits": self._hits,
"total": total,
"taux_cache": self._hits / total if total > 0 else 0,
}
c = Compteur("test")
print(c(1, 2, clé="valeur"))
print(c("bonjour"))
print(f"Nombre d'appels : {c.nb_appels}")
print(f"callable(c) = {callable(c)}")
@Memorisation
def fibonacci(n: int) -> int:
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(f"\nfib(10) = {fibonacci(10)}")
print(f"fib(10) = {fibonacci(10)}") # Depuis le cache
print(f"fib(15) = {fibonacci(15)}")
print(f"Statistiques : {fibonacci.statistiques()}")
[test] Appel #1 avec args=(1, 2), kwargs={'clé': 'valeur'}
[test] Appel #2 avec args=('bonjour',), kwargs={}
Nombre d'appels : 2
callable(c) = True
fib(10) = 55
fib(10) = 55
fib(15) = 610
Statistiques : {'calculs': 16, 'cache_hits': 15, 'total': 31, 'taux_cache': 0.4838709677419355}
Protocole de contexte#
Le protocole de contexte est ce qui rend les instructions with possibles. Un objet implémentant ce protocole peut être utilisé comme gestionnaire de contexte, garantissant qu’une action de nettoyage est toujours exécutée, même si une exception survient dans le bloc with.
Définition 20 (Protocole de contexte)
Un gestionnaire de contexte est un objet qui implémente :
__enter__(self): appelé en entrant dans le blocwith. La valeur retournée est assignée à la variable aprèsas(si présente).__exit__(self, type_exc, valeur_exc, traceback): appelé en sortant du blocwith, qu’une exception ait été levée ou non. Si une exception est active, les trois paramètres la décrivent ; sinon ils valentNone. Si__exit__retourne une valeur vraie, l’exception est supprimée.
import time
class Chronometre:
"""Gestionnaire de contexte pour mesurer le temps d'exécution."""
def __init__(self, nom: str = "opération") -> None:
self.nom = nom
self.duree: float | None = None
self._debut: float | None = None
def __enter__(self) -> "Chronometre":
self._debut = time.perf_counter()
print(f"⏱ Début de '{self.nom}'")
return self # Disponible après 'as'
def __exit__(self, type_exc, valeur_exc, traceback) -> bool:
self.duree = time.perf_counter() - self._debut
if type_exc is not None:
print(f"❌ Exception dans '{self.nom}' après {self.duree:.4f}s : "
f"{type_exc.__name__}: {valeur_exc}")
else:
print(f"✅ '{self.nom}' terminé en {self.duree:.4f}s")
return False # Ne supprime pas l'exception
class GestionnaireTransaction:
"""Simule une transaction avec commit ou rollback automatique."""
def __init__(self, base: list) -> None:
self._base = base
self._sauvegarde: list | None = None
def __enter__(self) -> list:
self._sauvegarde = self._base.copy()
return self._base
def __exit__(self, type_exc, valeur_exc, traceback) -> bool:
if type_exc is not None:
self._base.clear()
self._base.extend(self._sauvegarde)
print(f"Rollback effectué : {self._base}")
else:
print(f"Commit réussi : {self._base}")
return False
# Utilisation du chronomètre
with Chronometre("calcul de somme") as chrono:
total = sum(range(1_000_000))
print(f"Résultat : {total}, durée mémorisée : {chrono.duree:.4f}s")
# Transaction avec succès
donnees = [1, 2, 3]
with GestionnaireTransaction(donnees) as d:
d.append(4)
d.append(5)
print(f"Données après commit : {donnees}")
# Transaction avec échec → rollback automatique
donnees2 = [10, 20, 30]
try:
with GestionnaireTransaction(donnees2) as d:
d.append(40)
raise RuntimeError("Erreur simulée")
except RuntimeError:
pass
print(f"Données après rollback : {donnees2}")
⏱ Début de 'calcul de somme'
✅ 'calcul de somme' terminé en 0.0189s
Résultat : 499999500000, durée mémorisée : 0.0189s
Commit réussi : [1, 2, 3, 4, 5]
Données après commit : [1, 2, 3, 4, 5]
Rollback effectué : [10, 20, 30]
Données après rollback : [10, 20, 30]
Le protocole de contexte est exploré plus en détail dans le chapitre dédié aux gestionnaires de contexte, qui couvre notamment contextlib.contextmanager pour créer des gestionnaires à partir de générateurs.
typing.Protocol — duck typing statique#
Depuis Python 3.8, le module typing offre Protocol, qui formalise le duck typing pour les outils d’analyse statique (mypy, pyright). Un Protocol définit une interface structurelle : tout objet qui implémente les méthodes requises est considéré conforme, sans héritage explicite.
```{prf:definition} typing.Protocol
:label: definition-08-06
typing.Protocol permet de définir des protocoles structurels (structural subtyping). Une classe est considérée conforme à un protocole si elle implémente toutes les méthodes et attributs requis, que ce soit par héritage explicite ou non. C’est la formalisation statique du duck typing : le vérificateur de types peut valider la conformité sans que le code source modifie quoi que ce soit.
```{code-cell} python
from typing import Protocol, runtime_checkable
@runtime_checkable
class Dessinable(Protocol):
"""Protocole pour les objets qui peuvent être dessinés."""
def dessiner(self, canvas) -> None:
"""Dessine l'objet sur le canvas donné."""
...
def surface(self) -> float:
"""Retourne la surface occupée."""
...
class Sprite:
"""Sprite de jeu vidéo — conforme à Dessinable sans hériter."""
def __init__(self, x: float, y: float, taille: float) -> None:
self.x, self.y, self.taille = x, y, taille
def dessiner(self, canvas) -> None:
print(f"Dessin du sprite à ({self.x}, {self.y})")
def surface(self) -> float:
return self.taille ** 2
def deplacer(self, dx: float, dy: float) -> None:
self.x += dx
self.y += dy
class Bouton:
"""Bouton d'interface — conforme à Dessinable sans hériter."""
def __init__(self, largeur: float, hauteur: float) -> None:
self.largeur, self.hauteur = largeur, hauteur
def dessiner(self, canvas) -> None:
print(f"Dessin du bouton {self.largeur}×{self.hauteur}")
def surface(self) -> float:
return self.largeur * self.hauteur
# Vérification à l'exécution grâce à @runtime_checkable
s = Sprite(10.0, 20.0, 5.0)
b = Bouton(100.0, 50.0)
print(f"Sprite est Dessinable : {isinstance(s, Dessinable)}")
print(f"Bouton est Dessinable : {isinstance(b, Dessinable)}")
print(f"int est Dessinable : {isinstance(42, Dessinable)}")
def afficher_tout(elements: list) -> None:
"""Accepte tout objet Dessinable — duck typing."""
for elem in elements:
if isinstance(elem, Dessinable):
elem.dessiner(None)
print(f" Surface : {elem.surface():.1f}")
afficher_tout([s, b, 42]) # 42 est ignoré
Visualisation : tableau des méthodes spéciales#
Exemple 4 (Classe complète implémentant plusieurs protocoles)
Construisons un VecteurND qui implémente les protocoles de conteneur, numérique et de représentation.
import math
import operator
class VecteurND:
"""Vecteur à N dimensions avec opérations complètes."""
def __init__(self, *composantes: float) -> None:
self._composantes = tuple(composantes)
# ─── Protocole de représentation ───
def __repr__(self) -> str:
return f"VecteurND{self._composantes}"
def __str__(self) -> str:
comps = ", ".join(f"{c:.3g}" for c in self._composantes)
return f"[{comps}]"
# ─── Protocole de conteneur ───
def __len__(self) -> int:
return len(self._composantes)
def __getitem__(self, indice: int) -> float:
return self._composantes[indice]
def __iter__(self):
return iter(self._composantes)
def __contains__(self, valeur: float) -> bool:
return valeur in self._composantes
# ─── Protocole numérique ───
def __add__(self, other: "VecteurND") -> "VecteurND":
if len(self) != len(other):
raise ValueError("Dimensions incompatibles")
return VecteurND(*(a + b for a, b in zip(self, other)))
def __sub__(self, other: "VecteurND") -> "VecteurND":
if len(self) != len(other):
raise ValueError("Dimensions incompatibles")
return VecteurND(*(a - b for a, b in zip(self, other)))
def __mul__(self, scalaire: float) -> "VecteurND":
return VecteurND(*(c * scalaire for c in self))
def __rmul__(self, scalaire: float) -> "VecteurND":
return self.__mul__(scalaire)
def __neg__(self) -> "VecteurND":
return VecteurND(*(-c for c in self))
def __abs__(self) -> float:
return math.sqrt(sum(c**2 for c in self))
# ─── Protocole de comparaison ───
def __eq__(self, other: object) -> bool:
if not isinstance(other, VecteurND):
return NotImplemented
return self._composantes == other._composantes
def __hash__(self) -> int:
return hash(self._composantes)
# ─── Méthodes propres ───
def produit_scalaire(self, other: "VecteurND") -> float:
if len(self) != len(other):
raise ValueError("Dimensions incompatibles")
return sum(a * b for a, b in zip(self, other))
def normalise(self) -> "VecteurND":
norme = abs(self)
if norme == 0:
raise ValueError("Impossible de normaliser le vecteur nul")
return self * (1 / norme)
v1 = VecteurND(1.0, 2.0, 3.0)
v2 = VecteurND(4.0, 5.0, 6.0)
print(f"v1 = {v1}")
print(f"v2 = {v2}")
print(f"v1 + v2 = {v1 + v2}")
print(f"v1 - v2 = {v1 - v2}")
print(f"3 * v1 = {3 * v1}")
print(f"|v1| = {abs(v1):.4f}")
print(f"v1 · v2 = {v1.produit_scalaire(v2)}")
print(f"v1 normalisé = {v1.normalise()}")
print(f"len(v1) = {len(v1)}")
print(f"v1[0] = {v1[0]}, v1[-1] = {v1[-1]}")
print(f"2.0 in v1 : {2.0 in v1}")
print(f"list(v1) = {list(v1)}")
v1 = [1, 2, 3]
v2 = [4, 5, 6]
v1 + v2 = [5, 7, 9]
v1 - v2 = [-3, -3, -3]
3 * v1 = [3, 6, 9]
|v1| = 3.7417
v1 · v2 = 32.0
v1 normalisé = [0.267, 0.535, 0.802]
len(v1) = 3
v1[0] = 1.0, v1[-1] = 3.0
2.0 in v1 : True
list(v1) = [1.0, 2.0, 3.0]
Résumé#
Ce chapitre a exploré le modèle de données de Python et ses protocoles via les méthodes spéciales :
Le modèle de données Python est un système cohérent où les opérations du langage (
+,[],len(),for,with…) sont traduites en appels de méthodes spéciales (dunder methods). Implémenter ces méthodes intègre vos classes dans tout l’écosystème Python.Le protocole des conteneurs (
__len__,__getitem__,__setitem__,__contains__,__iter__…) permet de créer des collections personnalisées compatibles avecfor,in,len()et les fonctions de la bibliothèque standard.Le protocole numérique (
__add__,__mul__,__radd__,__iadd__…) permet de créer des types numériques qui supportent les opérateurs arithmétiques. Les versions réfléchies (__radd__) gèrent le cas où l’opérande gauche ne connaît pas votre type.Le protocole d’appel (
__call__) permet de créer des objets appelables qui se comportent comme des fonctions tout en maintenant un état.Le protocole de contexte (
__enter__,__exit__) est la base des gestionnaires de contexte (with), garantissant le nettoyage des ressources même en cas d’exception.typing.Protocolformalise le duck typing pour les outils d’analyse statique : il définit des interfaces structurelles sans imposer d’héritage explicite.
Le chapitre suivant introduit les dataclasses — un mécanisme pour générer automatiquement la plupart des méthodes spéciales (__init__, __repr__, __eq__) pour des classes qui servent avant tout à contenir des données.