Protocoles et méthodes spéciales#

Hide code cell source

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

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é par len(obj), doit retourner un entier ≥ 0.

  • __getitem__(self, clé) : appelé par obj[clé], permet l’accès par indice ou clé.

  • __setitem__(self, clé, valeur) : appelé par obj[clé] = valeur.

  • __delitem__(self, clé) : appelé par del obj[clé].

  • __contains__(self, élément) : appelé par élément in obj. Si absent, Python itère sur l’objet.

  • __iter__(self) : appelé par iter(obj) et les boucles for. Doit retourner un itérateur.

  • __reversed__(self) : appelé par reversed(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 (retourne NotImplemented).

  • Opérateurs en place (préfixe i) : __iadd__, __isub__… appelés par +=, -=… Ils modifient l’objet en place et retournent self.

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 bloc with. La valeur retournée est assignée à la variable après as (si présente).

  • __exit__(self, type_exc, valeur_exc, traceback) : appelé en sortant du bloc with, qu’une exception ait été levée ou non. Si une exception est active, les trois paramètres la décrivent ; sinon ils valent None. 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#

Hide code cell source

fig, ax = plt.subplots(figsize=(16, 10))
ax.set_xlim(0, 16)
ax.set_ylim(0, 10)
ax.axis('off')
ax.set_title("Méthodes spéciales Python — organisées par protocole",
             fontsize=14, fontweight='bold', pad=15)

palette = sns.color_palette("Set2", 8)

protocoles = [
    {
        "nom": "Représentation",
        "couleur": palette[0],
        "methodes": [
            ("__repr__", "repr(obj)"),
            ("__str__", "str(obj), print()"),
            ("__format__", "format(obj, spec)"),
            ("__bytes__", "bytes(obj)"),
        ]
    },
    {
        "nom": "Conteneur",
        "couleur": palette[1],
        "methodes": [
            ("__len__", "len(obj)"),
            ("__getitem__", "obj[clé]"),
            ("__setitem__", "obj[clé] = val"),
            ("__delitem__", "del obj[clé]"),
            ("__contains__", "x in obj"),
            ("__iter__", "for x in obj"),
            ("__reversed__", "reversed(obj)"),
        ]
    },
    {
        "nom": "Numérique",
        "couleur": palette[2],
        "methodes": [
            ("__add__  / __radd__", "obj + other"),
            ("__sub__  / __rsub__", "obj - other"),
            ("__mul__  / __rmul__", "obj * other"),
            ("__truediv__", "obj / other"),
            ("__iadd__", "obj += other"),
            ("__neg__, __abs__", "-obj, abs(obj)"),
        ]
    },
    {
        "nom": "Comparaison",
        "couleur": palette[3],
        "methodes": [
            ("__eq__", "obj == other"),
            ("__ne__", "obj != other"),
            ("__lt__", "obj < other"),
            ("__le__", "obj <= other"),
            ("__gt__", "obj > other"),
            ("__ge__", "obj >= other"),
            ("__hash__", "hash(obj), set, dict"),
        ]
    },
    {
        "nom": "Cycle de vie",
        "couleur": palette[4],
        "methodes": [
            ("__init__", "obj = Classe(...)"),
            ("__new__", "création de l'objet"),
            ("__del__", "destruction (GC)"),
            ("__init_subclass__", "héritage de la classe"),
        ]
    },
    {
        "nom": "Appel & Contexte",
        "couleur": palette[5],
        "methodes": [
            ("__call__", "obj(args)"),
            ("__enter__", "with obj as x"),
            ("__exit__", "fin du bloc with"),
        ]
    },
    {
        "nom": "Attributs",
        "couleur": palette[6],
        "methodes": [
            ("__getattr__", "obj.attr (manquant)"),
            ("__setattr__", "obj.attr = val"),
            ("__delattr__", "del obj.attr"),
            ("__getattribute__", "tout accès à attr"),
        ]
    },
    {
        "nom": "Descripteurs",
        "couleur": palette[7],
        "methodes": [
            ("__get__", "accès à l'attr via desc."),
            ("__set__", "assignation via desc."),
            ("__delete__", "suppression via desc."),
            ("__set_name__", "lors de la définition"),
        ]
    },
]

# Disposition : 4 colonnes × 2 rangées
col_w = 4.0
row_h = 4.8
cols = 4

for idx, proto in enumerate(protocoles):
    col = idx % cols
    row = idx // cols

    x0 = 0.1 + col * col_w
    y0 = 9.6 - row * row_h

    col_rgb = proto["couleur"]

    # Entête du protocole
    header = patches.FancyBboxPatch(
        (x0, y0 - 0.45), col_w - 0.2, 0.45,
        boxstyle="round,pad=0.05", linewidth=2,
        edgecolor=col_rgb, facecolor=col_rgb, alpha=0.85
    )
    ax.add_patch(header)
    ax.text(x0 + (col_w - 0.2) / 2, y0 - 0.22,
            proto["nom"], ha='center', va='center',
            fontsize=10, fontweight='bold', color='white')

    # Corps
    body = patches.FancyBboxPatch(
        (x0, y0 - 0.45 - len(proto["methodes"]) * 0.42),
        col_w - 0.2, len(proto["methodes"]) * 0.42,
        boxstyle="round,pad=0.05", linewidth=1.5,
        edgecolor=col_rgb, facecolor=col_rgb, alpha=0.08
    )
    ax.add_patch(body)

    for j, (meth, desc) in enumerate(proto["methodes"]):
        y_m = y0 - 0.45 - (j + 0.5) * 0.42
        ax.text(x0 + 0.12, y_m + 0.07,
                meth, ha='left', va='center',
                fontsize=7.5, color=col_rgb, fontfamily='monospace',
                fontweight='bold')
        ax.text(x0 + 0.12, y_m - 0.1,
                desc, ha='left', va='center',
                fontsize=6.5, color='#444', style='italic')

plt.tight_layout()
plt.show()
_images/93669c9b9df89dc5a9ea2e36ac57a48213367e2c5e718e7b8b97b3c9bb8e85ec.png

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 avec for, 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.Protocol formalise 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.