Classes et instances#

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 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.

Remarque 15

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.

Définition 9 (Classe et instance)

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 :

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}")
p1 : (3.0, 4.0), distance = 5.0
p2 : (1.0, 2.0), distance = 2.23606797749979
Dimensions (attribut de classe) : 2

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.

Définition 10 (Attribut d’instance vs attribut de classe)

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.

# 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)
Point.dimensions = 2
p3.dimensions = 3
p1.dimensions = 2

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.

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'))}")
p4 = Point(5.0, 12.0), distance = 13.0
p5 = Point(0.0, 0.0), distance = 0.0
Coordonnée valide ? True
Infini valide ? False

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.

Remarque 16

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.

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}")
Rayon : 5.0
Diamètre : 10.0
Aire : 78.5398
Après c.diametre = 20 : rayon = 10.0
Erreur attendue : Le rayon doit être positif, reçu -1

Définition 11 (Propriété (property))

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__.

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
t1 == t3 : True
t1 > t2 : True
t2 <= t1 : True
sorted : [Temperature(0.0), Temperature(100.0), Temperature(100.0)]
Ensemble : {Temperature(0.0), Temperature(100.0)}

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

Hide code cell source

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()
_images/5084d3f3218e4e704e000d4d9c24c57172006225416e2aa67a7e912b718e4c90.png

Exemple 3 (Classe complète — exemple récapitulatif)

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__.

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}")
Rectangle(4.0, 6.0)
Rectangle 5.0×5.0 (aire=25.0)
r1 < r2 : True
r1 == Rectangle(6, 4) : True
Nombre de rectangles créés : 3

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.