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

# Classes et instances

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

## Le paradigme orienté objet

La **programmation orientée objet** (POO) est un paradigme qui structure le code autour d'**objets** — des entités qui combinent des données (appelées *attributs*) et des comportements (appelés *méthodes*). Plutôt que de penser à un programme comme une suite d'instructions qui transforment des données, on le pense comme une collection d'objets qui collaborent en s'envoyant des messages.

Ce paradigme a émergé dans les années 1960 avec Simula, s'est popularisé avec Smalltalk dans les années 1970, puis avec C++ et Java dans les années 1980 et 1990. Aujourd'hui, Python intègre la POO de façon naturelle et souple, sans en faire un carcan rigide.

Les quatre piliers classiques de la POO sont :

- **L'encapsulation** : regrouper les données et les comportements au sein d'un même objet, et contrôler l'accès aux détails internes.
- **L'abstraction** : exposer une interface simple et cacher la complexité interne.
- **L'héritage** : permettre à une classe de réutiliser et d'étendre le comportement d'une autre (voir le chapitre suivant).
- **Le polymorphisme** : permettre à différents types d'objets de répondre au même message de façons différentes.

**La philosophie Python vis-à-vis de l'encapsulation** est particulièrement intéressante et diffère de langages comme Java ou C++. En Python, il n'existe pas de modificateurs d'accès stricts (`private`, `protected`, `public`). À la place, Python adopte une convention sociale : les attributs et méthodes dont le nom commence par un underscore (`_nom`) sont considérés comme *internes* à la classe, et ceux dont le nom commence par deux underscores (`__nom`) déclenchent un mécanisme de *name mangling* qui rend leur accès accidentel plus difficile depuis l'extérieur.

```{prf:remark}
:label: remark-06-01
La philosophie Python est résumée dans la formule célèbre : *"We are all consenting adults here"* (nous sommes tous des adultes consentants). Cela signifie que Python fait confiance au programmeur plutôt que de lui imposer des barrières syntaxiques strictes. Un attribut préfixé par `_` signifie « ne touchez pas à ça depuis l'extérieur, sauf si vous savez ce que vous faites ». Le compilateur ne vous en empêchera pas, mais la convention est claire.
```

En pratique, l'approche Python conduit à un code plus concis et plus lisible. On évite la prolifération de méthodes `getX()` et `setX()` qui encombrent les classes Java, et on utilise à la place les **propriétés** (que nous verrons plus loin dans ce chapitre) pour contrôler l'accès aux attributs tout en conservant une syntaxe naturelle.

## Définir une classe

En Python, on définit une classe avec le mot-clé `class`. La méthode spéciale `__init__` joue le rôle de constructeur : elle est automatiquement appelée lors de la création d'une nouvelle instance.

```{prf:definition} Classe et instance
:label: definition-06-01
Une **classe** est un plan ou un moule qui décrit la structure et le comportement d'un type d'objet. Une **instance** est un objet concret créé à partir de ce plan. On appelle aussi la classe le *type* de l'instance. En Python, `type(obj)` retourne la classe dont `obj` est une instance.
```

Voici comment définir une classe représentant un point dans le plan :

```{code-cell} python
class Point:
    """Représente un point dans le plan cartésien."""

    # Attribut de classe : partagé par toutes les instances
    dimensions = 2

    def __init__(self, x: float, y: float) -> None:
        # Attributs d'instance : propres à chaque instance
        self.x = x
        self.y = y

    def distance_origine(self) -> float:
        """Calcule la distance à l'origine."""
        return (self.x ** 2 + self.y ** 2) ** 0.5


# Création de deux instances distinctes
p1 = Point(3.0, 4.0)
p2 = Point(1.0, 2.0)

print(f"p1 : ({p1.x}, {p1.y}), distance = {p1.distance_origine()}")
print(f"p2 : ({p2.x}, {p2.y}), distance = {p2.distance_origine()}")
print(f"Dimensions (attribut de classe) : {Point.dimensions}")
```

Le paramètre `self` mérite une explication : c'est une **référence à l'instance courante**. Lorsque Python exécute `p1.distance_origine()`, il appelle en réalité `Point.distance_origine(p1)`. Le paramètre `self` reçoit l'instance `p1`. Ce mécanisme est explicite en Python — contrairement à `this` implicite en Java ou C++ — ce qui rend le code plus transparent.

```{prf:definition} Attribut d'instance vs attribut de classe
:label: definition-06-02
Un **attribut d'instance** est défini sur `self` dans `__init__` (ou ailleurs) et appartient à une instance spécifique. Chaque instance possède sa propre copie. Un **attribut de classe** est défini directement dans le corps de la classe, en dehors de toute méthode. Il est partagé par toutes les instances et accessible via `NomClasse.attribut` ou `self.attribut`. Si une instance modifie un attribut de classe via `self`, Python crée un attribut d'instance qui *masque* l'attribut de classe pour cette instance uniquement.
```

```{code-cell} python
# Illustration du masquage d'attribut de classe
p3 = Point(0.0, 0.0)
p3.dimensions = 3   # Crée un attribut d'instance, ne modifie pas la classe

print(f"Point.dimensions = {Point.dimensions}")   # Toujours 2
print(f"p3.dimensions = {p3.dimensions}")         # 3 (attribut d'instance)
print(f"p1.dimensions = {p1.dimensions}")         # 2 (attribut de classe)
```

## Méthodes

Python distingue trois types de méthodes selon leur premier paramètre et leurs décorateurs.

### Méthodes d'instance

Les **méthodes d'instance** reçoivent `self` comme premier argument et ont accès à l'état de l'instance. C'est le cas le plus courant, que nous avons vu avec `distance_origine`.

### Méthodes de classe

Les **méthodes de classe** sont décorées avec `@classmethod` et reçoivent `cls` (la classe elle-même) comme premier argument au lieu de `self`. Elles sont utiles pour créer des **constructeurs alternatifs** ou pour manipuler des attributs de classe.

```{code-cell} python
class Point:
    """Représente un point dans le plan cartésien."""

    dimensions = 2

    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y

    @classmethod
    def depuis_tuple(cls, coords: tuple) -> "Point":
        """Constructeur alternatif à partir d'un tuple (x, y)."""
        return cls(coords[0], coords[1])

    @classmethod
    def origine(cls) -> "Point":
        """Retourne le point à l'origine."""
        return cls(0.0, 0.0)

    @staticmethod
    def valider_coordonnee(valeur: float) -> bool:
        """Vérifie qu'une coordonnée est un nombre fini."""
        import math
        return math.isfinite(valeur)

    def distance_origine(self) -> float:
        return (self.x ** 2 + self.y ** 2) ** 0.5

    def __repr__(self) -> str:
        return f"Point({self.x!r}, {self.y!r})"


p4 = Point.depuis_tuple((5.0, 12.0))
p5 = Point.origine()
print(f"p4 = {p4}, distance = {p4.distance_origine()}")
print(f"p5 = {p5}, distance = {p5.distance_origine()}")
print(f"Coordonnée valide ? {Point.valider_coordonnee(3.14)}")
print(f"Infini valide ? {Point.valider_coordonnee(float('inf'))}")
```

### Méthodes statiques

Les **méthodes statiques** sont décorées avec `@staticmethod` et ne reçoivent ni `self` ni `cls`. Ce sont essentiellement des fonctions ordinaires qui vivent dans l'espace de noms de la classe pour des raisons d'organisation logique. Elles ne peuvent ni accéder à l'état de l'instance ni à celui de la classe.

```{prf:remark}
:label: remark-06-02
Quand choisir entre `@classmethod` et `@staticmethod` ? Utilisez `@classmethod` quand la méthode doit accéder à la classe (pour l'instancier, lire un attribut de classe, etc.). Utilisez `@staticmethod` quand la méthode est liée conceptuellement à la classe mais n'a besoin ni de la classe ni de l'instance. Si la méthode n'a pas besoin d'être dans la classe du tout, envisagez d'en faire une fonction ordinaire au niveau du module.
```

## Propriétés

Les propriétés permettent de contrôler l'accès aux attributs tout en conservant une syntaxe d'accès naturelle (`obj.attribut` au lieu de `obj.get_attribut()`). Elles s'implémentent avec les décorateurs `@property`, `@nom.setter` et `@nom.deleter`.

```{code-cell} python
class Cercle:
    """Cercle défini par son rayon."""

    def __init__(self, rayon: float) -> None:
        self._rayon = rayon  # Attribut "privé" par convention

    @property
    def rayon(self) -> float:
        """Le rayon du cercle (doit être positif)."""
        return self._rayon

    @rayon.setter
    def rayon(self, valeur: float) -> None:
        if valeur < 0:
            raise ValueError(f"Le rayon doit être positif, reçu {valeur}")
        self._rayon = valeur

    @rayon.deleter
    def rayon(self) -> None:
        raise AttributeError("Impossible de supprimer le rayon d'un cercle")

    @property
    def diametre(self) -> float:
        """Le diamètre, calculé à partir du rayon."""
        return 2 * self._rayon

    @diametre.setter
    def diametre(self, valeur: float) -> None:
        self.rayon = valeur / 2  # Passe par le setter de rayon (validation incluse)

    @property
    def aire(self) -> float:
        """L'aire du cercle (propriété en lecture seule)."""
        import math
        return math.pi * self._rayon ** 2


c = Cercle(5.0)
print(f"Rayon : {c.rayon}")
print(f"Diamètre : {c.diametre}")
print(f"Aire : {c.aire:.4f}")

c.diametre = 20.0
print(f"Après c.diametre = 20 : rayon = {c.rayon}")

try:
    c.rayon = -1
except ValueError as e:
    print(f"Erreur attendue : {e}")
```

```{prf:definition} Propriété (*property*)
:label: definition-06-03
Une **propriété** est un descripteur qui intercepte les accès en lecture (`__get__`), en écriture (`__set__`) et en suppression (`__delete__`) d'un attribut. Le décorateur `@property` transforme une méthode en getter. Les décorateurs `@nom.setter` et `@nom.deleter` définissent le setter et le deleter. Les propriétés permettent d'**ajouter de la validation ou du calcul** sans changer l'interface publique de la classe.
```

L'intérêt majeur des propriétés en Python est qu'elles permettent de commencer par un attribut simple (`self.rayon = valeur`) et de le transformer ultérieurement en propriété avec validation, **sans casser le code existant qui utilise la classe** — l'interface externe reste identique.

## Représentation

Python définit deux méthodes spéciales pour la représentation textuelle d'un objet : `__str__` et `__repr__`.

```{prf:definition} `__str__` et `__repr__`
:label: definition-06-04
- `__repr__` doit retourner une **représentation non ambiguë** de l'objet, idéalement une expression Python qui permettrait de recréer l'objet. Elle est destinée aux développeurs. C'est ce qu'affiche le REPL (l'interpréteur interactif) et `repr(obj)`.
- `__str__` doit retourner une **représentation lisible** destinée aux utilisateurs finaux. C'est ce qu'affiche `print(obj)` et `str(obj)`.

Si `__str__` n'est pas définie, Python utilise `__repr__` à sa place. Donc, **définir `__repr__` est plus important que définir `__str__`**.
```

```{code-cell} python
import math

class Vecteur2D:
    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y

    def __repr__(self) -> str:
        # repr : précis et non ambigu, permet de recréer l'objet
        return f"Vecteur2D({self.x!r}, {self.y!r})"

    def __str__(self) -> str:
        # str : lisible pour l'utilisateur
        norme = math.sqrt(self.x**2 + self.y**2)
        angle = math.degrees(math.atan2(self.y, self.x))
        return f"Vecteur({self.x}, {self.y}) — norme={norme:.2f}, angle={angle:.1f}°"


v = Vecteur2D(3.0, 4.0)
print(repr(v))   # Appelle __repr__
print(str(v))    # Appelle __str__
print(v)         # Appelle __str__ (via print)

# Dans une liste, repr est utilisé
print([v, Vecteur2D(1.0, 0.0)])
```

## Comparaison et hashage

Par défaut, deux instances d'une classe sont égales uniquement si elles sont le même objet en mémoire (`is`). Pour définir une égalité sémantique, il faut implémenter `__eq__`.

```{code-cell} python
import functools

@functools.total_ordering
class Temperature:
    """Température en degrés Celsius."""

    def __init__(self, celsius: float) -> None:
        self._celsius = celsius

    @property
    def celsius(self) -> float:
        return self._celsius

    @property
    def fahrenheit(self) -> float:
        return self._celsius * 9 / 5 + 32

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Temperature):
            return NotImplemented
        return self._celsius == other._celsius

    def __lt__(self, other: "Temperature") -> bool:
        if not isinstance(other, Temperature):
            return NotImplemented
        return self._celsius < other._celsius

    def __hash__(self) -> int:
        return hash(self._celsius)

    def __repr__(self) -> str:
        return f"Temperature({self._celsius})"


t1 = Temperature(100.0)
t2 = Temperature(0.0)
t3 = Temperature(100.0)

print(f"t1 == t3 : {t1 == t3}")
print(f"t1 > t2 : {t1 > t2}")   # Fourni par @total_ordering
print(f"t2 <= t1 : {t2 <= t1}") # Fourni par @total_ordering
print(f"sorted : {sorted([t1, t2, t3])}")

# Utilisable dans un ensemble car __hash__ est défini
ensemble = {t1, t2, t3}
print(f"Ensemble : {ensemble}")  # t1 et t3 sont identiques, donc 2 éléments
```

```{prf:definition} `@functools.total_ordering`
:label: definition-06-05
Le décorateur `@functools.total_ordering` permet de ne définir que `__eq__` et **une** des méthodes de comparaison (`__lt__`, `__le__`, `__gt__` ou `__ge__`). Il génère automatiquement les méthodes manquantes. C'est une façon pratique d'éviter de répéter six fois la même logique de comparaison.
```

```{prf:remark}
:label: remark-06-03
En Python, si vous définissez `__eq__`, Python **annule automatiquement** `__hash__` pour votre classe (en le mettant à `None`), car deux objets égaux doivent avoir le même hash. Si vous voulez que vos instances soient utilisables dans un `set` ou comme clés de `dict`, vous devez redéfinir explicitement `__hash__`. La convention la plus simple est `return hash(self.valeur_clé)`.
```

## Visualisation : anatomie d'une classe

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

fig, ax = plt.subplots(figsize=(14, 9))
ax.set_xlim(0, 14)
ax.set_ylim(0, 9)
ax.axis('off')
ax.set_title("Anatomie d'une classe Python : classe, attributs et instances",
             fontsize=14, fontweight='bold', pad=15)

# Palette de couleurs
c_classe   = '#2980b9'
c_instance = '#27ae60'
c_attr_cls = '#8e44ad'
c_attr_ins = '#e67e22'
c_methode  = '#c0392b'
c_fleche   = '#2c3e50'

# ─── Boîte de la classe (centre) ───
cls_x, cls_y = 5.0, 3.5
cls_w, cls_h = 4.0, 5.0

cls_box = patches.FancyBboxPatch(
    (cls_x, cls_y), cls_w, cls_h,
    boxstyle="round,pad=0.2", linewidth=3,
    edgecolor=c_classe, facecolor='#d6eaf8'
)
ax.add_patch(cls_box)
ax.text(cls_x + cls_w / 2, cls_y + cls_h - 0.4,
        'class Point', ha='center', va='center',
        fontsize=13, fontweight='bold', color=c_classe,
        fontfamily='monospace')

# Séparateur
ax.plot([cls_x + 0.2, cls_x + cls_w - 0.2],
        [cls_y + cls_h - 0.75, cls_y + cls_h - 0.75],
        color=c_classe, lw=1.5)

# Attribut de classe
ax.text(cls_x + 0.3, cls_y + cls_h - 1.15,
        'dimensions = 2', ha='left', va='center',
        fontsize=10, color=c_attr_cls, fontfamily='monospace')
ax.text(cls_x + cls_w - 0.2, cls_y + cls_h - 1.15,
        '← attr. classe', ha='right', va='center',
        fontsize=8, color=c_attr_cls, style='italic')

# Séparateur
ax.plot([cls_x + 0.2, cls_x + cls_w - 0.2],
        [cls_y + cls_h - 1.45, cls_y + cls_h - 1.45],
        color=c_classe, lw=1, linestyle='dashed', alpha=0.5)

# Méthodes
methodes = [
    ('__init__(self, x, y)', c_methode),
    ('distance_origine(self)', c_methode),
    ('@classmethod origine(cls)', '#7d3c98'),
    ('@staticmethod valider(v)', '#1a5276'),
    ('@property rayon', '#117a65'),
]
for i, (m, col) in enumerate(methodes):
    ax.text(cls_x + 0.3, cls_y + cls_h - 1.9 - i * 0.6,
            m, ha='left', va='center',
            fontsize=8.5, color=col, fontfamily='monospace')

# ─── Instance 1 ───
i1_x, i1_y = 0.3, 5.5
i1_w, i1_h = 3.0, 2.8
i1_box = patches.FancyBboxPatch(
    (i1_x, i1_y), i1_w, i1_h,
    boxstyle="round,pad=0.15", linewidth=2.5,
    edgecolor=c_instance, facecolor='#d5f5e3'
)
ax.add_patch(i1_box)
ax.text(i1_x + i1_w / 2, i1_y + i1_h - 0.35,
        'p1 = Point(3, 4)', ha='center', va='center',
        fontsize=9.5, fontweight='bold', color=c_instance,
        fontfamily='monospace')
ax.plot([i1_x + 0.15, i1_x + i1_w - 0.15],
        [i1_y + i1_h - 0.65, i1_y + i1_h - 0.65],
        color=c_instance, lw=1.2)
ax.text(i1_x + 0.3, i1_y + i1_h - 1.05,
        'self.x = 3', ha='left', va='center',
        fontsize=9, color=c_attr_ins, fontfamily='monospace')
ax.text(i1_x + 0.3, i1_y + i1_h - 1.55,
        'self.y = 4', ha='left', va='center',
        fontsize=9, color=c_attr_ins, fontfamily='monospace')
ax.text(i1_x + i1_w / 2, i1_y + 0.3,
        '← attr. instance', ha='center', va='center',
        fontsize=7.5, color=c_attr_ins, style='italic')

# ─── Instance 2 ───
i2_x, i2_y = 0.3, 1.8
i2_w, i2_h = 3.0, 2.8
i2_box = patches.FancyBboxPatch(
    (i2_x, i2_y), i2_w, i2_h,
    boxstyle="round,pad=0.15", linewidth=2.5,
    edgecolor=c_instance, facecolor='#d5f5e3'
)
ax.add_patch(i2_box)
ax.text(i2_x + i2_w / 2, i2_y + i2_h - 0.35,
        'p2 = Point(1, 2)', ha='center', va='center',
        fontsize=9.5, fontweight='bold', color=c_instance,
        fontfamily='monospace')
ax.plot([i2_x + 0.15, i2_x + i2_w - 0.15],
        [i2_y + i2_h - 0.65, i2_y + i2_h - 0.65],
        color=c_instance, lw=1.2)
ax.text(i2_x + 0.3, i2_y + i2_h - 1.05,
        'self.x = 1', ha='left', va='center',
        fontsize=9, color=c_attr_ins, fontfamily='monospace')
ax.text(i2_x + 0.3, i2_y + i2_h - 1.55,
        'self.y = 2', ha='left', va='center',
        fontsize=9, color=c_attr_ins, fontfamily='monospace')
ax.text(i2_x + i2_w / 2, i2_y + 0.3,
        '← attr. instance', ha='center', va='center',
        fontsize=7.5, color=c_attr_ins, style='italic')

# ─── Flèches instances → classe ───
# p1 → classe
ax.annotate('', xy=(cls_x, cls_y + cls_h * 0.75),
            xytext=(i1_x + i1_w, i1_y + i1_h * 0.5),
            arrowprops=dict(arrowstyle='->', color=c_fleche, lw=2,
                            connectionstyle='arc3,rad=-0.1'))
ax.text(3.3, 7.2, 'instance de', ha='center', va='center',
        fontsize=8, color=c_fleche, style='italic')

ax.annotate('', xy=(cls_x, cls_y + cls_h * 0.25),
            xytext=(i2_x + i2_w, i2_y + i2_h * 0.5),
            arrowprops=dict(arrowstyle='->', color=c_fleche, lw=2,
                            connectionstyle='arc3,rad=0.1'))
ax.text(3.3, 2.5, 'instance de', ha='center', va='center',
        fontsize=8, color=c_fleche, style='italic')

# ─── Instance 3 (à droite) ───
i3_x, i3_y = 10.7, 3.5
i3_w, i3_h = 3.0, 2.8
i3_box = patches.FancyBboxPatch(
    (i3_x, i3_y), i3_w, i3_h,
    boxstyle="round,pad=0.15", linewidth=2.5,
    edgecolor=c_instance, facecolor='#d5f5e3'
)
ax.add_patch(i3_box)
ax.text(i3_x + i3_w / 2, i3_y + i3_h - 0.35,
        'p3 = Point(0, 0)', ha='center', va='center',
        fontsize=9.5, fontweight='bold', color=c_instance,
        fontfamily='monospace')
ax.plot([i3_x + 0.15, i3_x + i3_w - 0.15],
        [i3_y + i3_h - 0.65, i3_y + i3_h - 0.65],
        color=c_instance, lw=1.2)
ax.text(i3_x + 0.3, i3_y + i3_h - 1.05,
        'self.x = 0', ha='left', va='center',
        fontsize=9, color=c_attr_ins, fontfamily='monospace')
ax.text(i3_x + 0.3, i3_y + i3_h - 1.55,
        'self.y = 0', ha='left', va='center',
        fontsize=9, color=c_attr_ins, fontfamily='monospace')
ax.text(i3_x + i3_w / 2, i3_y + 0.3,
        '← attr. instance', ha='center', va='center',
        fontsize=7.5, color=c_attr_ins, style='italic')

# p3 → classe
ax.annotate('', xy=(cls_x + cls_w, cls_y + cls_h * 0.5),
            xytext=(i3_x, i3_y + i3_h * 0.5),
            arrowprops=dict(arrowstyle='->', color=c_fleche, lw=2))
ax.text(10.05, 5.1, 'instance de', ha='center', va='center',
        fontsize=8, color=c_fleche, style='italic')

# ─── Légende ───
legend_items = [
    (patches.Patch(facecolor='#d6eaf8', edgecolor=c_classe, lw=2), 'Classe'),
    (patches.Patch(facecolor='#d5f5e3', edgecolor=c_instance, lw=2), 'Instance'),
    (patches.Patch(facecolor='white', edgecolor=c_attr_cls, lw=2), 'Attribut de classe'),
    (patches.Patch(facecolor='white', edgecolor=c_attr_ins, lw=2), 'Attribut d\'instance'),
    (patches.Patch(facecolor='white', edgecolor=c_methode, lw=2), 'Méthode'),
]
ax.legend(handles=[h for h, _ in legend_items],
          labels=[l for _, l in legend_items],
          loc='lower right', fontsize=9, framealpha=0.9)

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

```{prf:example} Classe complète — exemple récapitulatif
:label: example-06-01
Voici une classe `Rectangle` qui combine tous les concepts vus dans ce chapitre : attributs d'instance, attributs de classe, méthode d'instance, méthode de classe, méthode statique, propriété avec validation, `__repr__`, `__str__`, `__eq__` et `__hash__`.
```

```{code-cell} python
import functools
import math

@functools.total_ordering
class Rectangle:
    """Rectangle défini par sa largeur et sa hauteur."""

    # Attribut de classe
    nb_rectangles = 0

    def __init__(self, largeur: float, hauteur: float) -> None:
        self.largeur = largeur    # Passe par le setter (validation)
        self.hauteur = hauteur
        Rectangle.nb_rectangles += 1

    @property
    def largeur(self) -> float:
        return self._largeur

    @largeur.setter
    def largeur(self, valeur: float) -> None:
        if valeur <= 0:
            raise ValueError(f"La largeur doit être strictement positive, reçu {valeur}")
        self._largeur = valeur

    @property
    def hauteur(self) -> float:
        return self._hauteur

    @hauteur.setter
    def hauteur(self, valeur: float) -> None:
        if valeur <= 0:
            raise ValueError(f"La hauteur doit être strictement positive, reçu {valeur}")
        self._hauteur = valeur

    @property
    def aire(self) -> float:
        return self._largeur * self._hauteur

    @property
    def perimetre(self) -> float:
        return 2 * (self._largeur + self._hauteur)

    @property
    def diagonale(self) -> float:
        return math.sqrt(self._largeur**2 + self._hauteur**2)

    @classmethod
    def carre(cls, cote: float) -> "Rectangle":
        """Constructeur alternatif pour un carré."""
        return cls(cote, cote)

    @staticmethod
    def est_similaire(r1: "Rectangle", r2: "Rectangle") -> bool:
        """Vérifie si deux rectangles ont le même rapport largeur/hauteur."""
        return abs(r1.largeur / r1.hauteur - r2.largeur / r2.hauteur) < 1e-9

    def __repr__(self) -> str:
        return f"Rectangle({self._largeur!r}, {self._hauteur!r})"

    def __str__(self) -> str:
        return f"Rectangle {self._largeur}×{self._hauteur} (aire={self.aire})"

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Rectangle):
            return NotImplemented
        return self.aire == other.aire

    def __lt__(self, other: "Rectangle") -> bool:
        if not isinstance(other, Rectangle):
            return NotImplemented
        return self.aire < other.aire

    def __hash__(self) -> int:
        return hash(self.aire)


r1 = Rectangle(4.0, 6.0)
r2 = Rectangle.carre(5.0)
print(repr(r1))
print(str(r2))
print(f"r1 < r2 : {r1 < r2}")
print(f"r1 == Rectangle(6, 4) : {r1 == Rectangle(6.0, 4.0)}")
print(f"Nombre de rectangles créés : {Rectangle.nb_rectangles}")
```

## Résumé

Dans ce chapitre, nous avons posé les bases de la programmation orientée objet en Python :

- Le **paradigme orienté objet** structure le code en objets combinant données et comportements. Python l'adopte de façon souple, sans modificateurs d'accès stricts, en faisant confiance aux conventions (`_` pour interne, `__` pour *name mangling*).
- Une **classe** se définit avec `class`, et son constructeur `__init__` initialise les attributs de chaque nouvelle **instance** via `self`. Les **attributs d'instance** appartiennent à chaque objet ; les **attributs de classe** sont partagés par toutes les instances.
- Python offre trois types de méthodes : les **méthodes d'instance** (le cas courant, reçoivent `self`), les **méthodes de classe** (`@classmethod`, reçoivent `cls`, utiles comme constructeurs alternatifs), et les **méthodes statiques** (`@staticmethod`, ni `self` ni `cls`).
- Les **propriétés** (`@property`, `@setter`, `@deleter`) permettent de contrôler l'accès aux attributs avec validation et calcul, sans changer l'interface publique.
- `__repr__` et `__str__` contrôlent la représentation textuelle : `__repr__` pour les développeurs (précis), `__str__` pour les utilisateurs (lisible).
- `__eq__`, `__hash__` et les méthodes de comparaison définissent l'égalité et l'ordre. `@functools.total_ordering` évite de réécrire six méthodes.

Le chapitre suivant approfondit le mécanisme d'**héritage** : comment une classe peut étendre une autre, le problème de l'héritage multiple et son ordre de résolution (MRO), et les classes abstraites.
