---
jupytext:
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.16.0
kernelspec:
  name: python3
  display_name: Python 3
---

# Héritage et polymorphisme

```{code-cell} python
:tags: [hide-input]

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)
```

## Héritage simple

L'**héritage** est le mécanisme par lequel une classe (appelée *classe fille*, *sous-classe* ou *classe enfant*) acquiert automatiquement les attributs et méthodes d'une autre classe (appelée *classe parente*, *superclasse* ou *classe de base*). C'est l'un des outils fondamentaux pour éviter la répétition de code et modéliser des relations « est-un » entre concepts.

En Python, la syntaxe est concise : `class Fille(Parente):`. Si aucune classe parente n'est spécifiée, Python considère implicitement que la classe hérite de `object` — la racine de toute la hiérarchie de types en Python 3.

```{prf:definition} Héritage
:label: definition-07-01
L'**héritage** est une relation entre deux classes où la classe fille *hérite* de tous les attributs et méthodes de la classe parente. La classe fille peut **redéfinir** (*override*) des méthodes héritées pour modifier leur comportement, et **ajouter** de nouvelles méthodes et attributs spécifiques à elle. La relation d'héritage modélise la relation sémantique « est-un » : un `Chien` *est un* `Animal`.
```

Voici un exemple concret avec une hiérarchie de formes géométriques :

```{code-cell} python
import math

class Forme:
    """Classe de base pour toutes les formes géométriques."""

    def __init__(self, couleur: str = "blanc") -> None:
        self.couleur = couleur

    def aire(self) -> float:
        raise NotImplementedError("La sous-classe doit implémenter aire()")

    def perimetre(self) -> float:
        raise NotImplementedError("La sous-classe doit implémenter perimetre()")

    def description(self) -> str:
        return (f"{type(self).__name__} de couleur {self.couleur} : "
                f"aire={self.aire():.2f}, périmètre={self.perimetre():.2f}")

    def __repr__(self) -> str:
        return f"{type(self).__name__}(couleur={self.couleur!r})"


class Cercle(Forme):
    """Cercle défini par son rayon."""

    def __init__(self, rayon: float, couleur: str = "blanc") -> None:
        super().__init__(couleur)   # Appel au constructeur de Forme
        self.rayon = rayon

    def aire(self) -> float:
        return math.pi * self.rayon ** 2

    def perimetre(self) -> float:
        return 2 * math.pi * self.rayon

    def __repr__(self) -> str:
        return f"Cercle(rayon={self.rayon!r}, couleur={self.couleur!r})"


class Rectangle(Forme):
    """Rectangle défini par sa largeur et sa hauteur."""

    def __init__(self, largeur: float, hauteur: float,
                 couleur: str = "blanc") -> None:
        super().__init__(couleur)
        self.largeur = largeur
        self.hauteur = hauteur

    def aire(self) -> float:
        return self.largeur * self.hauteur

    def perimetre(self) -> float:
        return 2 * (self.largeur + self.hauteur)

    def __repr__(self) -> str:
        return (f"Rectangle(largeur={self.largeur!r}, "
                f"hauteur={self.hauteur!r}, couleur={self.couleur!r})")


class Carre(Rectangle):
    """Carré : cas particulier de Rectangle."""

    def __init__(self, cote: float, couleur: str = "blanc") -> None:
        super().__init__(cote, cote, couleur)

    def __repr__(self) -> str:
        return f"Carre(cote={self.largeur!r}, couleur={self.couleur!r})"


# Utilisation
formes = [
    Cercle(5.0, "rouge"),
    Rectangle(4.0, 6.0, "bleu"),
    Carre(3.0, "vert"),
]

for f in formes:
    print(f.description())
```

La fonction `super()` mérite une attention particulière. Elle retourne un objet proxy qui délègue les appels de méthode à la **classe parente dans le MRO** (voir section suivante). Son usage est préférable à appeler directement `Forme.__init__(self, ...)`, car il fonctionne correctement en cas d'héritage multiple.

### Redéfinition de méthodes

Lorsqu'une sous-classe redéfinit une méthode, la nouvelle méthode **remplace** la méthode parente pour les instances de la sous-classe. On peut toujours accéder à la méthode parente via `super()`.

```{code-cell} python
class Triangle(Forme):
    """Triangle défini par ses trois côtés."""

    def __init__(self, a: float, b: float, c: float,
                 couleur: str = "blanc") -> None:
        if not self._est_valide(a, b, c):
            raise ValueError(f"Les côtés {a}, {b}, {c} ne forment pas un triangle")
        super().__init__(couleur)
        self.a, self.b, self.c = a, b, c

    @staticmethod
    def _est_valide(a: float, b: float, c: float) -> bool:
        return a + b > c and b + c > a and a + c > b

    def perimetre(self) -> float:
        return self.a + self.b + self.c

    def aire(self) -> float:
        # Formule de Héron
        s = self.perimetre() / 2
        return math.sqrt(s * (s - self.a) * (s - self.b) * (s - self.c))

    def description(self) -> str:
        # Appel à la méthode parente + information supplémentaire
        base = super().description()
        return f"{base} [côtés: {self.a}, {self.b}, {self.c}]"


t = Triangle(3.0, 4.0, 5.0, "jaune")
print(t.description())
print(f"Est un triangle rectangle ? {abs(t.aire() - 6.0) < 1e-9}")
```

## Héritage multiple

Python, contrairement à Java, supporte l'**héritage multiple** : une classe peut hériter de plusieurs classes parentes simultanément. C'est une fonctionnalité puissante mais qui soulève une question fondamentale : en cas de conflit de méthodes, laquelle utiliser ?

```{prf:definition} Héritage multiple
:label: definition-07-02
L'**héritage multiple** permet à une classe d'hériter de plusieurs classes parentes : `class C(A, B):`. Python résout les conflits de méthodes grâce à l'algorithme **C3 de linéarisation**, qui produit un ordre de résolution de méthodes (*Method Resolution Order*, MRO) déterministe et cohérent.
```

```{code-cell} python
class Volant:
    def deplacer(self) -> str:
        return "Je vole"

    def capacite(self) -> str:
        return "Je peux voler"


class Nageant:
    def deplacer(self) -> str:
        return "Je nage"

    def capacite(self) -> str:
        return "Je peux nager"


class Canard(Volant, Nageant):
    """Le canard peut voler ET nager."""
    pass


class SuperCanard(Volant, Nageant):
    def deplacer(self) -> str:
        # Combiner les capacités en appelant super() avec le MRO
        return f"{super().deplacer()} et je nage aussi"


donald = Canard()
print(f"Canard.deplacer() = {donald.deplacer()}")
# Volant est en premier dans la liste des parents → sa méthode gagne
print(f"MRO de Canard : {[c.__name__ for c in Canard.__mro__]}")
```

### L'algorithme C3 et le MRO

L'**algorithme C3** calcule un ordre linéaire des classes qui respecte deux invariants :
1. Une classe apparaît avant ses parentes dans le MRO.
2. L'ordre relatif des parentes déclaré par le programmeur est préservé.

```{code-cell} python
class A:
    def methode(self) -> str:
        return "A"

class B(A):
    def methode(self) -> str:
        return f"B → {super().methode()}"

class C(A):
    def methode(self) -> str:
        return f"C → {super().methode()}"

class D(B, C):
    def methode(self) -> str:
        return f"D → {super().methode()}"


d = D()
print(f"Résultat : {d.methode()}")
print(f"MRO de D : {[cls.__name__ for cls in D.__mro__]}")
# MRO : D → B → C → A → object
# Chaque super() suit le MRO, pas seulement la classe parente directe
```

```{prf:remark}
:label: remark-07-01
La clé pour comprendre `super()` avec l'héritage multiple est que `super()` ne signifie pas « la classe parente directe » mais « la prochaine classe dans le MRO de l'instance courante ». Ainsi, quand `B.methode` appelle `super().methode()`, et que l'instance est de type `D` (dont le MRO est D→B→C→A→object), `super()` depuis `B` fait référence à `C`, pas à `A`. C'est pourquoi le résultat est `D → B → C → A` et non `D → B → A`.
```

### Le patron *mixin*

L'héritage multiple est particulièrement utile avec les **mixins** : des classes légères qui apportent une fonctionnalité spécifique sans être destinées à être instanciées seules.

```{code-cell} python
class ReprMixin:
    """Mixin qui fournit un __repr__ automatique."""

    def __repr__(self) -> str:
        attrs = ", ".join(
            f"{k}={v!r}"
            for k, v in vars(self).items()
            if not k.startswith('_')
        )
        return f"{type(self).__name__}({attrs})"


class JsonMixin:
    """Mixin qui permet la sérialisation JSON basique."""

    def to_dict(self) -> dict:
        return {k: v for k, v in vars(self).items() if not k.startswith('_')}


class Produit(ReprMixin, JsonMixin):
    def __init__(self, nom: str, prix: float, stock: int) -> None:
        self.nom = nom
        self.prix = prix
        self.stock = stock


p = Produit("Clavier mécanique", 89.99, 42)
print(repr(p))
print(p.to_dict())
```

## Classes abstraites

Une **classe abstraite** est une classe qui ne peut pas être instanciée directement et qui définit une **interface contractuelle** que ses sous-classes doivent respecter. En Python, elles s'implémentent avec le module `abc` (*Abstract Base Classes*).

```{prf:definition} Classe abstraite
:label: definition-07-03
Une **classe abstraite** est une classe qui contient au moins une **méthode abstraite** (décorée avec `@abstractmethod`). Elle ne peut pas être instanciée. Ses sous-classes concrètes *doivent* implémenter toutes les méthodes abstraites, sinon elles restent elles-mêmes abstraites. Les classes abstraites servent à définir des *contrats d'interface* — ce qu'une famille de classes doit être capable de faire.
```

```{code-cell} python
from abc import ABC, abstractmethod

class Serialisable(ABC):
    """Interface pour les objets qui peuvent être sérialisés."""

    @abstractmethod
    def serialiser(self) -> str:
        """Convertit l'objet en chaîne de caractères."""
        ...

    @abstractmethod
    def deserialiser(self, data: str) -> None:
        """Restaure l'état de l'objet depuis une chaîne."""
        ...

    def sauvegarder(self, chemin: str) -> None:
        """Méthode concrète qui utilise l'interface abstraite."""
        with open(chemin, 'w') as f:
            f.write(self.serialiser())
        print(f"Sauvegardé dans {chemin}")


class ConfigJSON(Serialisable):
    def __init__(self, donnees: dict) -> None:
        self.donnees = donnees

    def serialiser(self) -> str:
        import json
        return json.dumps(self.donnees, indent=2, ensure_ascii=False)

    def deserialiser(self, data: str) -> None:
        import json
        self.donnees = json.loads(data)


# Tenter d'instancier la classe abstraite lève une erreur
try:
    s = Serialisable()
except TypeError as e:
    print(f"Erreur attendue : {e}")

# La sous-classe concrète, elle, s'instancie normalement
cfg = ConfigJSON({"langue": "français", "version": 3})
print(cfg.serialiser())
```

Les classes abstraites du module `abc` ont un autre rôle : définir des **ABCs de la bibliothèque standard**, comme `collections.abc.Sequence`, `collections.abc.Mapping`, `collections.abc.Iterable`, etc. Ces ABCs servent de base pour les vérifications `isinstance` et comme source d'inspiration pour implémenter vos propres conteneurs.

## Polymorphisme

Le **polymorphisme** est la capacité d'un même code à fonctionner avec des objets de types différents. C'est l'une des forces majeures de Python.

```{prf:definition} Polymorphisme
:label: definition-07-04
Le **polymorphisme** (du grec *polys* = plusieurs, *morphê* = forme) désigne la capacité d'un code à traiter des objets de types différents de façon uniforme, à condition que ces objets exposent l'interface attendue. En Python, le polymorphisme est principalement réalisé via le ***duck typing*** : si un objet possède les méthodes et attributs attendus, il peut être utilisé, quelle que soit sa classe réelle.
```

```{code-cell} python
import math

# Hiérarchie de formes avec un protocole commun
class Ellipse(Forme):
    def __init__(self, a: float, b: float, couleur: str = "blanc") -> None:
        super().__init__(couleur)
        self.a = a   # Demi-grand axe
        self.b = b   # Demi-petit axe

    def aire(self) -> float:
        return math.pi * self.a * self.b

    def perimetre(self) -> float:
        # Approximation de Ramanujan
        h = ((self.a - self.b) / (self.a + self.b)) ** 2
        return math.pi * (self.a + self.b) * (1 + 3*h / (10 + math.sqrt(4 - 3*h)))


def afficher_statistiques(formes: list) -> None:
    """Fonctionne avec n'importe quelle liste de formes — duck typing."""
    total_aire = sum(f.aire() for f in formes)
    total_perimetre = sum(f.perimetre() for f in formes)
    plus_grande = max(formes, key=lambda f: f.aire())

    print(f"Nombre de formes : {len(formes)}")
    print(f"Aire totale : {total_aire:.2f}")
    print(f"Périmètre total : {total_perimetre:.2f}")
    print(f"Plus grande forme : {repr(plus_grande)}")


catalogue = [
    Cercle(3.0, "rouge"),
    Rectangle(4.0, 5.0, "bleu"),
    Triangle(3.0, 4.0, 5.0),
    Ellipse(6.0, 2.0, "violet"),
    Carre(4.0, "vert"),
]

afficher_statistiques(catalogue)
```

### Duck typing : protocoles structurels vs héritage nominal

Le *duck typing* est la philosophie centrale du polymorphisme en Python : « Si ça marche comme un canard et ça cancane comme un canard, c'est un canard. » Un objet n'a pas besoin d'hériter d'une classe particulière pour être utilisé là où cette classe est attendue — il suffit qu'il possède les méthodes nécessaires.

```{code-cell} python
# Un objet "ressemblant à une forme" sans hériter de Forme
class Losange:
    """Losange — ne hérite PAS de Forme."""

    def __init__(self, diagonale1: float, diagonale2: float) -> None:
        self.d1 = diagonale1
        self.d2 = diagonale2
        self.couleur = "gris"

    def aire(self) -> float:
        return (self.d1 * self.d2) / 2

    def perimetre(self) -> float:
        cote = math.sqrt((self.d1/2)**2 + (self.d2/2)**2)
        return 4 * cote

    def __repr__(self) -> str:
        return f"Losange({self.d1!r}, {self.d2!r})"


# Fonctionne parfaitement car Losange a aire() et perimetre()
catalogue_etendu = catalogue + [Losange(6.0, 8.0)]
afficher_statistiques(catalogue_etendu)
```

## Composition vs héritage

L'héritage est souvent présenté comme *la* façon de réutiliser du code, mais il n'est pas toujours le meilleur outil. Le **principe « favor composition over inheritance »** (préférez la composition à l'héritage) recommande d'utiliser l'héritage uniquement pour modéliser de vraies relations « est-un », et de préférer la **composition** (un objet *contient* d'autres objets) pour les relations « a-un ».

```{prf:remark}
:label: remark-07-02
L'héritage crée un **couplage fort** entre la classe fille et la classe parente : toute modification de la parente peut affecter la fille de façon imprévue. La composition est plus souple : si un objet délègue une fonctionnalité à un autre objet, on peut changer l'objet délégué sans modifier le code appelant. En Python, la composition s'exprime naturellement en stockant des objets comme attributs.
```

```{code-cell} python
# ❌ Héritage problématique : une Pile qui hérite de list
class PileParHeritage(list):
    """Pile LIFO par héritage — expose toutes les méthodes de list !"""

    def empiler(self, element) -> None:
        self.append(element)

    def depiler(self):
        return self.pop()


# ✅ Composition : une Pile qui contient une list
class PileParComposition:
    """Pile LIFO par composition — interface contrôlée."""

    def __init__(self) -> None:
        self._elements: list = []

    def empiler(self, element) -> None:
        self._elements.append(element)

    def depiler(self):
        if self.est_vide():
            raise IndexError("La pile est vide")
        return self._elements.pop()

    def sommet(self):
        if self.est_vide():
            raise IndexError("La pile est vide")
        return self._elements[-1]

    def est_vide(self) -> bool:
        return len(self._elements) == 0

    def __len__(self) -> int:
        return len(self._elements)

    def __repr__(self) -> str:
        return f"Pile({self._elements})"


pile1 = PileParHeritage()
pile1.empiler(1)
pile1.empiler(2)
# Problème : insert, sort, reverse, extend... toutes les méthodes de list sont exposées
print(f"Méthodes de PileParHeritage : {[m for m in dir(pile1) if not m.startswith('_')]}")

pile2 = PileParComposition()
pile2.empiler(10)
pile2.empiler(20)
pile2.empiler(30)
print(f"Sommet : {pile2.sommet()}")
print(f"Dépiler : {pile2.depiler()}")
print(f"État : {pile2}")
# Interface propre : seules les méthodes utiles sont exposées
print(f"Méthodes de PileParComposition : {[m for m in dir(pile2) if not m.startswith('_')]}")
```

## `isinstance` et `issubclass`

Python fournit deux fonctions built-in pour inspecter les relations d'héritage :

```{code-cell} python
c = Cercle(5.0)
print(f"isinstance(c, Cercle)    : {isinstance(c, Cercle)}")
print(f"isinstance(c, Forme)     : {isinstance(c, Forme)}")    # héritage
print(f"isinstance(c, object)    : {isinstance(c, object)}")   # tout hérite d'object
print(f"isinstance(c, Rectangle) : {isinstance(c, Rectangle)}")

print(f"issubclass(Carre, Rectangle) : {issubclass(Carre, Rectangle)}")
print(f"issubclass(Carre, Forme)     : {issubclass(Carre, Forme)}")
print(f"issubclass(Forme, object)    : {issubclass(Forme, object)}")
```

```{prf:remark}
:label: remark-07-03
Préférez `isinstance(obj, ClasseAttendue)` à `type(obj) == ClasseAttendue`. La version avec `type()` retourne `False` si `obj` est une instance d'une *sous-classe*, ce qui brise le polymorphisme. `isinstance` est aussi plus flexible : elle accepte un tuple de classes comme deuxième argument (`isinstance(obj, (int, float))`). Cela dit, dans l'esprit du *duck typing*, vérifier le type explicitement est souvent un signe que le code pourrait être mieux structuré.
```

## Visualisation : arbre d'héritage et MRO

```{code-cell} python
:tags: [hide-input]

fig, axes = plt.subplots(1, 2, figsize=(16, 8))

# ─── Graphe de gauche : arbre d'héritage de nos formes ───
ax = axes[0]
ax.set_xlim(-0.5, 10.5)
ax.set_ylim(-0.5, 8.5)
ax.axis('off')
ax.set_title("Hiérarchie d'héritage des formes", fontsize=13, fontweight='bold')

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

nodes = {
    'object':    (5.0, 7.5),
    'Forme':     (5.0, 6.0),
    'Cercle':    (1.5, 4.5),
    'Ellipse':   (3.5, 4.5),
    'Rectangle': (6.5, 4.5),
    'Triangle':  (8.5, 4.5),
    'Carre':     (6.5, 3.0),
}
colors = {
    'object': palette[0], 'Forme': palette[1],
    'Cercle': palette[2], 'Ellipse': palette[3],
    'Rectangle': palette[4], 'Triangle': palette[5],
    'Carre': palette[6],
}
edges = [
    ('object', 'Forme'),
    ('Forme', 'Cercle'), ('Forme', 'Ellipse'),
    ('Forme', 'Rectangle'), ('Forme', 'Triangle'),
    ('Rectangle', 'Carre'),
]

for (parent, enfant) in edges:
    px, py = nodes[parent]
    ex, ey = nodes[enfant]
    ax.annotate('', xy=(px, py - 0.28), xytext=(ex, ey + 0.28),
                arrowprops=dict(arrowstyle='->', color='#555', lw=2))

for nom, (x, y) in nodes.items():
    box = patches.FancyBboxPatch(
        (x - 0.9, y - 0.25), 1.8, 0.5,
        boxstyle="round,pad=0.1", linewidth=2,
        edgecolor=colors[nom], facecolor=colors[nom], alpha=0.25
    )
    ax.add_patch(box)
    ax.add_patch(patches.FancyBboxPatch(
        (x - 0.9, y - 0.25), 1.8, 0.5,
        boxstyle="round,pad=0.1", linewidth=2,
        edgecolor=colors[nom], facecolor='none'
    ))
    ax.text(x, y, nom, ha='center', va='center',
            fontsize=10, fontweight='bold', color=colors[nom],
            fontfamily='monospace')

ax.text(5.0, 0.3,
        "Les flèches pointent vers la classe parente\n(sens de l'héritage)",
        ha='center', va='center', fontsize=8, color='#555', style='italic')

# ─── Graphe de droite : MRO de D(B, C) avec A commun ───
ax2 = axes[1]
ax2.set_xlim(-0.5, 10.5)
ax2.set_ylim(-0.5, 8.5)
ax2.axis('off')
ax2.set_title("MRO de D(B, C) — algorithme C3", fontsize=13, fontweight='bold')

mro_palette = sns.color_palette("Set2", 6)

mro_nodes = {
    'object': (5.0, 7.0),
    'A':      (5.0, 5.5),
    'B':      (3.0, 4.0),
    'C':      (7.0, 4.0),
    'D':      (5.0, 2.5),
}
mro_colors = dict(zip(mro_nodes, mro_palette))
mro_edges = [
    ('object', 'A'), ('A', 'B'), ('A', 'C'), ('B', 'D'), ('C', 'D')
]
for (p, e) in mro_edges:
    px, py = mro_nodes[p]
    ex, ey = mro_nodes[e]
    ax2.annotate('', xy=(px, py - 0.28), xytext=(ex, ey + 0.28),
                 arrowprops=dict(arrowstyle='->', color='#555', lw=2))

for nom, (x, y) in mro_nodes.items():
    ax2.add_patch(patches.FancyBboxPatch(
        (x - 0.7, y - 0.25), 1.4, 0.5,
        boxstyle="round,pad=0.1", linewidth=2,
        edgecolor=mro_colors[nom], facecolor=mro_colors[nom], alpha=0.3
    ))
    ax2.add_patch(patches.FancyBboxPatch(
        (x - 0.7, y - 0.25), 1.4, 0.5,
        boxstyle="round,pad=0.1", linewidth=2,
        edgecolor=mro_colors[nom], facecolor='none'
    ))
    ax2.text(x, y, nom, ha='center', va='center',
             fontsize=13, fontweight='bold', color=mro_colors[nom],
             fontfamily='monospace')

# Ordre MRO affiché en bas
mro_order = ['D', 'B', 'C', 'A', 'object']
mro_y = 0.8
mro_xs = np.linspace(1.0, 9.0, len(mro_order))
for i, (cls, x) in enumerate(zip(mro_order, mro_xs)):
    col = mro_colors[cls]
    ax2.add_patch(patches.FancyBboxPatch(
        (x - 0.55, mro_y - 0.22), 1.1, 0.44,
        boxstyle="round,pad=0.05", linewidth=1.5,
        edgecolor=col, facecolor=col, alpha=0.3
    ))
    ax2.text(x, mro_y, cls, ha='center', va='center',
             fontsize=10, fontweight='bold', color=col,
             fontfamily='monospace')
    if i < len(mro_order) - 1:
        ax2.annotate('', xy=(mro_xs[i+1] - 0.6, mro_y),
                     xytext=(x + 0.6, mro_y),
                     arrowprops=dict(arrowstyle='->', color='#333', lw=1.5))

ax2.text(5.0, 0.2, "MRO : D → B → C → A → object",
         ha='center', va='center', fontsize=9,
         color='#333', fontweight='bold',
         fontfamily='monospace',
         bbox=dict(boxstyle='round,pad=0.3', facecolor='#fff9e6',
                   edgecolor='#e6a817', alpha=0.9))

plt.tight_layout()
plt.show()
```

## Résumé

Ce chapitre a couvert les mécanismes d'héritage et de polymorphisme en Python :

- L'**héritage simple** (`class Fille(Parente)`) permet de réutiliser et d'étendre le comportement d'une classe existante. `super()` appelle la prochaine classe dans le MRO — indispensable pour le chaînage correct des constructeurs.
- L'**héritage multiple** (`class C(A, B)`) est supporté par Python grâce à l'**algorithme C3**, qui calcule un ordre de résolution de méthodes (MRO) déterministe. `__mro__` permet de l'inspecter. Le patron **mixin** exploite l'héritage multiple pour ajouter des fonctionnalités transversales.
- Les **classes abstraites** (`abc.ABC`, `@abstractmethod`) définissent des contrats d'interface que les sous-classes concrètes doivent honorer. Elles ne peuvent pas être instanciées directement.
- Le **polymorphisme** permet à un même code de fonctionner avec des objets de types différents. En Python, il repose sur le ***duck typing*** : ce qui compte, c'est l'interface exposée, pas la classe d'appartenance.
- **Composition vs héritage** : l'héritage modélise les relations « est-un » ; la composition modélise les relations « a-un ». La composition est souvent plus flexible et moins couplée.
- `isinstance` et `issubclass` permettent d'inspecter les relations d'héritage. Préférer `isinstance` à `type(obj) ==` pour respecter le polymorphisme.

Le chapitre suivant explore le **modèle de données de Python** et les méthodes spéciales (dunder methods) qui permettent d'intégrer vos classes dans le langage : opérateurs arithmétiques, protocole itérable, protocole d'appel, et bien d'autres.
