Classes et instances#
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) etrepr(obj).__str__doit retourner une représentation lisible destinée aux utilisateurs finaux. C’est ce qu’afficheprint(obj)etstr(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#
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 viaself. 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çoiventcls, utiles comme constructeurs alternatifs), et les méthodes statiques (@staticmethod, niselfnicls).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.