Le Polymorphisme en Python
Apprenez le polymorphisme Python : surcharge de méthodes, duck typing, surcharge d'opérateurs et interfaces abstraites — avec des exemples clairs.
Le polymorphisme (du grec « plusieurs formes ») permet à un seul morceau de code de fonctionner avec des objets de types différents, à condition que ces objets supportent l'interface attendue. On appelle le même nom de méthode sur chaque objet, et chaque objet répond selon sa propre implémentation.
Le polymorphisme est l'un des quatre piliers de la programmation orientée objet, aux côtés de l'encapsulation, l'héritage et l'abstraction. C'est ce qui fait que les fonctions acceptant un type de classe de base fonctionnent automatiquement avec n'importe quelle sous-classe.
Ce chapitre couvre :
- Ce qu'est le polymorphisme et pourquoi il est important
- Le polymorphisme par surcharge de méthodes
- Le polymorphisme par duck typing
- La surcharge d'opérateurs — une forme de polymorphisme intégrée dans Python
- Les classes de base abstraites comme moyen formel de définir des interfaces polymorphiques
- Les schémas pratiques et les pièges courants
Avant de lire ce chapitre, assurez-vous d'être à l'aise avec les classes et objets Python et l'héritage Python.
Pourquoi le polymorphisme est-il important ?
Sans polymorphisme, une fonction qui travaille avec des animaux aurait besoin d'une chaîne explicite de if/elif pour chaque type d'animal :
def make_sound(animal):
if type(animal).__name__ == "Dog":
print("Woof!")
elif type(animal).__name__ == "Cat":
print("Meow!")
elif type(animal).__name__ == "Bird":
print("Tweet!")
# ... add a new branch every time you add a new animal typeCette approche est fragile. Chaque nouveau type d'animal nécessite de modifier cette fonction. Avec le polymorphisme, on écrit :
def make_sound(animal):
animal.speak() # works for any object that has a speak() methodAjouter un nouveau type d'animal ne requiert que de définir sa méthode speak() — la fonction elle-même ne change jamais. C'est le principe ouvert/fermé : ouvert à l'extension, fermé à la modification.
Le polymorphisme par surcharge de méthodes
La forme de polymorphisme la plus courante en Python est la surcharge de méthodes : une sous-classe fournit sa propre version d'une méthode définie par la classe parente.
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return f"{self.name} makes a sound."
class Dog(Animal):
def speak(self):
return f"{self.name} says woof!"
class Cat(Animal):
def speak(self):
return f"{self.name} says meow!"
class Bird(Animal):
def speak(self):
return f"{self.name} says tweet!"Une seule boucle fonctionne maintenant pour les trois types :
animals = [Dog("Rex"), Cat("Whiskers"), Bird("Tweety")]
for animal in animals:
print(animal.speak())
# Rex says woof!
# Whiskers says meow!
# Tweety says tweet!animal.speak() est distribué vers la version correcte à l'exécution selon le type réel de l'objet. Cette distribution à l'exécution s'appelle dispatch dynamique ou liaison tardive.
Étendre ou remplacer la méthode parente
Lorsque vous effectuez une surcharge, vous pouvez soit remplacer complètement le comportement du parent, soit l'étendre en utilisant super() :
class Animal:
def speak(self):
print("[Animal vocalization]")
class Dog(Animal):
def speak(self):
super().speak() # keep the parent's output
print("Woof! (Dog override adds this)")
Dog().speak()
# [Animal vocalization]
# Woof! (Dog override adds this)Utilisez super() lorsque la version parente effectue une configuration ou une journalisation utile qui doit encore s'exécuter. Omettez-le lorsque vous souhaitez remplacer entièrement le comportement.
Le polymorphisme par duck typing
Le système de types de Python est structurel plutôt que nominal. Un objet n'a pas besoin d'appartenir à une hiérarchie de classes particulière ; il suffit qu'il possède les bonnes méthodes. C'est ce qu'on appelle le duck typing — nommé d'après le dicton « si ça marche comme un canard et que ça cancane comme un canard, c'est un canard ».
class Dog:
def speak(self):
return "Woof!"
class Robot:
def speak(self):
return "Beep boop."
class Human:
def speak(self):
return "Hello!"
def introduce(entity):
print(entity.speak())
introduce(Dog()) # Woof!
introduce(Robot()) # Beep boop.
introduce(Human()) # Hello!Dog, Robot et Human ne partagent aucune classe parente commune (en dehors de l'object natif). Pourtant, introduce() fonctionne avec les trois parce que chacun possède une méthode speak(). La fonction ne vérifie pas le type d'entity — elle appelle simplement la méthode et fait confiance à ce que l'objet réponde correctement.
Quand utiliser le duck typing plutôt que l'héritage ?
| Situation | Approche préférée |
|---|---|
| Les objets sont logiquement liés (tous des animaux) | Hiérarchie d'héritage |
| Les objets ne sont pas liés mais partagent un comportement | Duck typing |
| Vous souhaitez imposer l'interface au moment de la définition | Classes de base abstraites |
| Travail avec des types natifs ou des classes tierces que vous ne pouvez pas modifier | Duck typing |
Le duck typing est idiomatique en Python et est largement utilisé dans la bibliothèque standard — par exemple, len() fonctionne sur tout objet qui définit __len__, quelle que soit sa classe.
Le polymorphisme avec des fonctions et des boucles
Vous pouvez écrire une seule fonction qui traite différents types de manière uniforme grâce au polymorphisme. Considérons une application de dessin :
class Circle:
def __init__(self, radius):
self.radius = radius
def area(self):
import math
return math.pi * self.radius ** 2
def describe(self):
return f"Circle with radius {self.radius}"
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
def describe(self):
return f"Rectangle {self.width}x{self.height}"
class Triangle:
def __init__(self, base, height):
self.base = base
self.height = height
def area(self):
return 0.5 * self.base * self.height
def describe(self):
return f"Triangle base={self.base} height={self.height}"
shapes = [Circle(5), Rectangle(4, 6), Triangle(3, 8)]
for shape in shapes:
print(f"{shape.describe()}: area = {shape.area():.2f}")
# Circle with radius 5: area = 78.54
# Rectangle 4x6: area = 24.00
# Triangle base=3 height=8: area = 12.00La boucle appelle area() et describe() sur chaque forme sans se soucier de la classe à laquelle appartient l'objet. L'ajout ultérieur d'une classe Pentagon ne nécessite que d'écrire la nouvelle classe — la boucle ne change pas.
La surcharge d'opérateurs
Les opérateurs arithmétiques et de comparaison de Python sont également polymorphiques. + sur des entiers additionne des nombres ; + sur des chaînes les concatène ; + sur des listes les fusionne. Python réalise cela grâce aux méthodes spéciales (dunder).
Vous pouvez faire en sorte que vos propres classes répondent aux opérateurs en définissant ces méthodes :
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __mul__(self, scalar):
return Vector(self.x * scalar, self.y * scalar)
def __repr__(self):
return f"Vector({self.x}, {self.y})"
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2) # Vector(4, 6)
print(v1 * 3) # Vector(3, 6)Le même opérateur + se comporte maintenant différemment selon que les opérandes sont des entiers, des chaînes ou des objets Vector. C'est le polymorphisme au niveau des opérateurs.
Pour une plongée approfondie dans les méthodes spéciales de Python, voir Les méthodes magiques Python.
Le polymorphisme avec les classes de base abstraites
Les classes de base abstraites (ABC) poussent le duck typing un cran plus loin en imposant l'interface au moment de la définition de la classe. Si une sous-classe ne parvient pas à implémenter une méthode requise, Python lève une TypeError au moment où vous tentez de l'instancier.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float:
"""Return the area of the shape."""
@abstractmethod
def perimeter(self) -> float:
"""Return the perimeter of the shape."""
class Circle(Shape):
def __init__(self, radius: float):
self.radius = radius
def area(self) -> float:
import math
return math.pi * self.radius ** 2
def perimeter(self) -> float:
import math
return 2 * math.pi * self.radius
class Square(Shape):
def __init__(self, side: float):
self.side = side
def area(self) -> float:
return self.side ** 2
def perimeter(self) -> float:
return 4 * self.side
def print_info(shape: Shape) -> None:
print(f"Area: {shape.area():.2f}")
print(f"Perimeter: {shape.perimeter():.2f}")
print_info(Circle(5))
# Area: 78.54
# Perimeter: 31.42
print_info(Square(4))
# Area: 16.00
# Perimeter: 16.00Si vous tentez d'instancier une sous-classe qui n'implémente pas area() :
class Blob(Shape):
pass # forgot to implement area() and perimeter()
b = Blob()
# TypeError: Can't instantiate abstract class Blob with abstract methods area, perimeterLes ABC vous offrent le filet de sécurité d'un contrat formel tout en permettant un polymorphisme à l'exécution.
ABC vs. Duck Typing — Lequel choisir ?
- Le duck typing est plus simple et plus flexible. Préférez-le pour les petites bases de code internes et les scripts.
- Les ABC rendent l'interface attendue explicite, détectent rapidement les bugs de méthodes manquantes et apparaissent dans l'auto-complétion des IDE et les vérificateurs de types. Préférez-les dans les grandes bases de code, les bibliothèques publiques et partout où vous souhaitez imposer un contrat.
Un exemple concret : le traitement des paiements
Le polymorphisme brille dans les architectures de type plugin. Considérons un système de paiement qui doit prendre en charge plusieurs fournisseurs :
from abc import ABC, abstractmethod
class PaymentProvider(ABC):
@abstractmethod
def charge(self, amount: float, currency: str) -> bool:
"""Attempt to charge the given amount. Return True on success."""
@abstractmethod
def refund(self, transaction_id: str) -> bool:
"""Refund a previous transaction. Return True on success."""
class StripeProvider(PaymentProvider):
def charge(self, amount: float, currency: str) -> bool:
print(f"[Stripe] Charged {amount} {currency}")
return True
def refund(self, transaction_id: str) -> bool:
print(f"[Stripe] Refunded transaction {transaction_id}")
return True
class PayPalProvider(PaymentProvider):
def charge(self, amount: float, currency: str) -> bool:
print(f"[PayPal] Charged {amount} {currency}")
return True
def refund(self, transaction_id: str) -> bool:
print(f"[PayPal] Refunded transaction {transaction_id}")
return True
def process_order(provider: PaymentProvider, amount: float) -> None:
success = provider.charge(amount, "USD")
if success:
print("Order complete.")
process_order(StripeProvider(), 99.99)
# [Stripe] Charged 99.99 USD
# Order complete.
process_order(PayPalProvider(), 49.5)
# [PayPal] Charged 49.5 USD
# Order complete.process_order ne sait pas et ne se soucie pas de savoir si elle reçoit un StripeProvider ou un PayPalProvider. Ajouter un nouveau CryptoProvider ne nécessite que d'écrire la nouvelle classe — rien d'autre ne change. C'est le polymorphisme offrant une extensibilité concrète.
Pièges courants
Piège 1 : Vérifier les types avec type() plutôt qu'isinstance()
Comparer type(obj) == Dog défait le polymorphisme car cela renvoie False pour les sous-classes. Préférez isinstance(obj, Animal), qui renvoie True pour Dog et toute future sous-classe :
class Animal:
pass
class Dog(Animal):
pass
d = Dog()
# Fragile — breaks for subclasses:
print(type(d) == Animal) # False
# Correct — subclass-aware:
print(isinstance(d, Animal)) # TruePiège 2 : Signatures de méthodes incohérentes
Le polymorphisme suppose que toutes les implémentations d'une méthode acceptent les mêmes arguments. Si Dog.speak() requiert un argument que Cat.speak() n'accepte pas, les appelants qui les traitent de manière uniforme rencontreront des erreurs :
# Inconsistent — will cause errors in a loop
class Dog:
def speak(self, volume): # extra argument!
return f"Woof at volume {volume}"
class Cat:
def speak(self):
return "Meow!"Gardez des signatures de méthodes cohérentes entre les classes polymorphiques.
Piège 3 : Arguments par défaut mutables dans les méthodes surchargées
Il s'agit d'un piège plus général en Python, mais qui fait trébucher dans les hiérarchies de classes : n'utilisez jamais un objet mutable (liste, dict) comme valeur d'argument par défaut — il est créé une seule fois et partagé entre tous les appels.
# Bug: the list is shared across all instances
class Item:
def __init__(self, tags=[]): # BAD
self.tags = tags
# Fix:
class Item:
def __init__(self, tags=None):
self.tags = tags if tags is not None else []Résumé
| Concept | Ce que cela signifie |
|---|---|
| Surcharge de méthodes | Une sous-classe fournit sa propre version d'une méthode parente |
| Dispatch dynamique | Python choisit la bonne version de méthode à l'exécution |
| Duck typing | Tout objet possédant les bonnes méthodes fonctionne, quelle que soit sa classe |
| Surcharge d'opérateurs | Les méthodes dunder (__add__, __len__, …) rendent les opérateurs polymorphiques |
| Classes de base abstraites | Imposent formellement l'interface que les sous-classes doivent implémenter |
Le polymorphisme est ce qui rend le code extensible sans modification. Écrivez des fonctions qui dépendent du comportement (noms de méthodes), et non de types concrets, et votre code s'adapte naturellement aux nouvelles classes sans changements.