Le polymorphisme : une interface commune, des comportements différents

Objectifs de la leçon
  • Expliquer ce qu’est le polymorphisme en termes simples et avec un exemple
  • Écrire du code qui fonctionne sur des objets de types différents via une interface commune, sans vérifier le type
  • Comprendre pourquoi le polymorphisme remplace les chaînes de if/elif selon le type
  • Connaître le Duck Typing — le polymorphisme sans héritage commun

Qu’allons-nous apprendre ?

  • Ce qu’est le polymorphisme (Polymorphism).
  • Une interface commune (Common Interface) : la même méthode, un comportement différent pour chaque type.
  • Pourquoi il remplace le if type == ....
  • Le Duck Typing : la version souple de Python.

Pourquoi en avons-nous besoin ?

Supposons qu’un système de paiement doive débiter un client, et que le client puisse payer par carte bancaire, par PayPal ou par virement bancaire. Sans polymorphisme :

def pay(method, amount):
    if method["type"] == "credit":
        print("Charging card", method["card"], amount)
    elif method["type"] == "paypal":
        print("Redirecting", method["email"], "to PayPal for", amount)
    elif method["type"] == "bank":
        print("Waiting for transfer of", amount, "to account", method["account"])

Et que se passe-t-il quand on ajoute Apple Pay ? Il faut ajouter un elif ici, mais aussi dans la fonction de remboursement, et dans le rapport mensuel, et dans la vérification des frais… Chaque fonction qui gère des paiements contient la même chaîne de if. On en oublie une, et il y a un bug.

Dans la première leçon de POO, c’était le « problème 4 ». Le polymorphisme en est la solution.

Explication à partir des bases

Qu’est-ce que le polymorphisme ?

Le polymorphisme (du grec : « plusieurs formes ») = la capacité de travailler avec des objets de types différents via la même interface, où chaque objet effectue l’action à sa façon.

En termes simples : le même appel (method.pay(100)) → un comportement différent, selon le type de l’objet. Le code appelant n’a pas besoin de savoir quel type il détient.

Analogie : une télécommande universelle

La télécommande a un bouton « marche ». Vous appuyez dessus face à une télévision, et elle s’allume. Face à un climatiseur, il commence à refroidir. Face à une enceinte, elle commence à jouer. Le même bouton (une interface), une action différente pour chaque appareil. Et vous, qui tenez la télécommande, n’avez pas besoin de savoir comment chaque appareil fonctionne à l’intérieur. Un nouvel appareil arrive ? S’il « comprend » le bouton, la télécommande fonctionne avec lui sans aucun changement.

Comment ça marche : une interface commune

  1. Dans le parent, définissez l’interface : quelles méthodes ont tous les types (pay, refund, fee).
  2. Chaque enfant redéfinit les méthodes à sa façon.
  3. Le code qui les utilise travaille face à l’interface : method.pay(amount), et non face aux types.

Nous l’avons déjà vu ! Dans le describe() d’Employee, qui appelait self.monthly_cost() et atteignait la bonne version d’un manager ou d’un stagiaire.

L’avantage principal : Open/Closed

Avec le polymorphisme, ajouter un nouveau type (Apple Pay) = écrire une seule nouvelle classe. Pas une seule ligne du code existant qui utilise les paiements ne change. Le système est ouvert à l’extension et fermé à la modification, un principe important de la conception logicielle.

Le Duck Typing

En Python, le polymorphisme n’exige pas l’héritage. Python ne vérifie pas « l’objet hérite-t-il de PaymentMethod ? ». Il essaie simplement d’appeler la méthode. Si la méthode existe, cela fonctionne.

« Si ça marche comme un canard et que ça cancane comme un canard, c’est un canard. »

Le Duck Typing = « peu importe ce que tu es, ce qui compte, c’est ce que tu sais faire ». Nous l’avons déjà utilisé : len() fonctionne sur str, list, dict et set, car ils ont tous une « longueur ». for fonctionne sur tout ce que l’on peut parcourir.

Et pourtant, hériter d’un parent commun est la façon recommandée quand on conçoit une interface volontairement : cela documente ce qu’est l’interface, et permet du code partagé.

Syntaxe

class Base:
    def action(self):                    # the common interface
        raise NotImplementedError("subclasses must implement action()")

class TypeA(Base):
    def action(self):
        return "A does it this way"

class TypeB(Base):
    def action(self):
        return "B does it another way"

for item in [TypeA(), TypeB()]:          # polymorphic code:
    print(item.action())                 # one call, many behaviors, no if/elif

raise NotImplementedError dans le parent = « chaque enfant doit implémenter cette méthode ». Si un enfant a oublié, vous obtenez une erreur claire au moment de l’appel.

Premier exemple de code

Le système de paiement, avec le polymorphisme. week-3/payments.py :

class PaymentMethod:
    def __init__(self, owner):
        self.owner = owner

    def pay(self, amount):
        raise NotImplementedError("subclasses must implement pay()")

    def fee(self, amount):
        return 0

class CreditCard(PaymentMethod):
    def __init__(self, owner, last_digits):
        super().__init__(owner)
        self.last_digits = last_digits

    def pay(self, amount):
        return "Charged " + str(amount) + " to card ****" + self.last_digits

    def fee(self, amount):
        return round(amount * 0.02, 2)

class PayPal(PaymentMethod):
    def __init__(self, owner, email):
        super().__init__(owner)
        self.email = email

    def pay(self, amount):
        return "Sent " + str(amount) + " via PayPal from " + self.email

class BankTransfer(PaymentMethod):
    def pay(self, amount):
        return "Waiting for a bank transfer of " + str(amount) + " from " + self.owner

def checkout(method, amount):
    total = amount + method.fee(amount)
    print(method.pay(total), "| fee:", method.fee(amount))

methods = [CreditCard("Dana", "4321"), PayPal("Avi", "avi@mail.com"), BankTransfer("Noa")]
for method in methods:
    checkout(method, 100)

Sortie :

Charged 102.0 to card ****4321 | fee: 2.0
Sent 100 via PayPal from avi@mail.com | fee: 0
Waiting for a bank transfer of 100 from Noa | fee: 0

Analyse du code ligne par ligne

CodeExplication
class PaymentMethodL’interface : chaque moyen de paiement doit avoir pay, et a fee (0 par défaut).
raise NotImplementedErrorUne « méthode abstraite » : elle définit une interface, sans implémentation.
CreditCard.pay, PayPal.pay, BankTransfer.payChacun implémente pay à sa façon.
CreditCard.feeUne redéfinition : des frais de 2 %. Les autres héritent du 0 du parent.
def checkout(method, amount):Le code polymorphe : il ne vérifie pas le type, il ne contient aucun if. Il appelle seulement method.fee() et method.pay().
La boucleExactement la même fonction, trois comportements différents.

Un autre exemple

Ajouter un nouveau type — sans toucher au code existant. Puis le Duck Typing.

class ApplePay(PaymentMethod):
    def __init__(self, owner, device):
        super().__init__(owner)
        self.device = device

    def pay(self, amount):
        return "Paid " + str(amount) + " with Apple Pay on " + self.device

    def fee(self, amount):
        return 1

checkout(ApplePay("Tom", "iPhone 15"), 100)
Paid 101 with Apple Pay on iPhone 15 | fee: 1

checkout n’a pas du tout changé. Nous avons ajouté une classe, et cela fonctionne. Dans la version if/elif, il aurait fallu modifier chaque fonction qui gère des paiements.

Le Duck Typing — un objet qui n’hérite pas de PaymentMethod :

class GiftCard:
    def __init__(self, code):
        self.code = code

    def pay(self, amount):
        return "Redeemed " + str(amount) + " from gift card " + self.code

    def fee(self, amount):
        return 0

checkout(GiftCard("GIFT-2026"), 100)
Redeemed 100 from gift card GIFT-2026 | fee: 0

GiftCard n’hérite pas de PaymentMethod, mais il a pay et fee, donc checkout fonctionne avec lui. C’est le Duck Typing. (Dans un vrai projet, il vaut quand même mieux qu’il hérite, pour que l’interface soit documentée et explicite.)

Un autre exemple familier : les notifications

class Notification:
    def __init__(self, recipient):
        self.recipient = recipient

    def send(self, text):
        raise NotImplementedError

class EmailNotification(Notification):
    def send(self, text):
        return "Email to " + self.recipient + ": " + text

class SmsNotification(Notification):
    def send(self, text):
        return "SMS to " + self.recipient + ": " + text[:20]

def notify_all(channels, text):
    for channel in channels:
        print(channel.send(text))

notify_all([EmailNotification("dana@mail.com"), SmsNotification("050-1234567")],
           "Your order #1042 has shipped and will arrive tomorrow")
Email to dana@mail.com: Your order #1042 has shipped and will arrive tomorrow
SMS to 050-1234567: Your order #1042 has

Chaque canal a ses propres règles (le SMS est coupé à 20 caractères), et notify_all ne le sait pas et ne s’en soucie pas.

Erreur courante

Erreur 1 : vérifier le type au lieu d’utiliser le polymorphisme.

def checkout(method, amount):
    if isinstance(method, CreditCard):
        ...
    elif isinstance(method, PayPal):
        ...

Cela nous ramène exactement au problème de départ, simplement avec des classes. Si vous vous surprenez à écrire if isinstance(...) sur vos propres classes pour décider comment effectuer une action, c’est le signe que l’action devrait être une méthode que chaque classe implémente.

Erreur 2 : oublier d’implémenter une méthode abstraite.

class Cash(PaymentMethod):
    pass

checkout(Cash("Rina"), 50)
NotImplementedError: subclasses must implement pay()

Et c’est une bonne chose : l’erreur est claire et indique exactement ce qui manque. Sans le raise dans le parent, vous obtiendriez un comportement silencieux et faux, ou None au lieu d’une chaîne.

Erreur 3 : une interface incohérente entre les types.

class CreditCard(PaymentMethod):
    def pay(self, amount, cvv):
        ...

Maintenant, checkout (qui appelle method.pay(total)) échoue uniquement sur les cartes bancaires. Toutes les implémentations doivent recevoir les mêmes paramètres. Une information propre à un type (comme un CVV) va dans le __init__ ou dans un attribut de ce type.

Erreur 4 : croire que le polymorphisme exige l’héritage. En Python, non (le Duck Typing). Mais hériter d’une base commune documente l’interface, et évite les erreurs. Préférez-le quand vous concevez.

Exercices

Les exercices n’utilisent que ce qui a été vu dans cette leçon et les précédentes. Essayez d’abord seul, et n’ouvrez la solution qu’ensuite.

Ce que vous pratiquez sur cette page : le polymorphisme — le même appel pour plusieurs types, où chaque type se comporte différemment ; ajouter un nouveau type sans modifier le code existant ; le Duck Typing et NotImplementedError.

À la fin de la page, vous saurez :

  • Remplacer une chaîne de if selon le type par une méthode partagée que chaque type implémente
  • Ajouter un nouveau type sans toucher au code qui l’utilise
  • Expliquer ce qu’est le Duck Typing
  • Obliger les enfants à implémenter une méthode grâce à NotImplementedError

Exercice 1: À la place d’une chaîne de if

Ce que vous construisez : des moyens de paiement à la caisse — sans vérifier dans le code le type de chacun.

Le code que vous remplacez :

def pay(method, amount):
    if method["type"] == "credit":
        return "Charging card " + str(amount)
    elif method["type"] == "paypal":
        return "Redirecting to PayPal " + str(amount)

Ce qu’il faut écrire :

  1. Une classe de base nommée PaymentMethod, avec pay qui reçoit un montant et lance NotImplementedError.
  2. Deux enfants : CreditCard et PayPal. Chacun redéfinit pay et renvoie sa propre phrase de la sortie.
  3. Une fonction nommée checkout qui reçoit un moyen de paiement et un montant, et appelle pay — sans if sur le type.

Code de test :

for method in [CreditCard(), PayPal()]:
    print(checkout(method, 100))

La sortie souhaitée :

Charging card 100
Redirecting to PayPal 100
Afficher la solution
class PaymentMethod:
    def pay(self, amount):
        raise NotImplementedError("subclasses must implement pay()")


class CreditCard(PaymentMethod):
    def pay(self, amount):
        return "Charging card " + str(amount)


class PayPal(PaymentMethod):
    def pay(self, amount):
        return "Redirecting to PayPal " + str(amount)


def checkout(method, amount):
    return method.pay(amount)


for method in [CreditCard(), PayPal()]:
    print(checkout(method, 100))
Charging card 100
Redirecting to PayPal 100

checkout ne sait pas, et n’a pas besoin de savoir, de quel type de paiement il s’agit — il appelle seulement pay. Chaque type l’implémente différemment. C’est le polymorphisme.

Exercice 2: Un nouveau type — sans toucher à checkout

Ce que vous construisez : la suite de la caisse — l’ajout d’un nouveau moyen de paiement et de frais.

Ce qu’il faut écrire :

  1. Un nouvel enfant nommé ApplePay, avec pay qui renvoie sa propre phrase de la sortie.
  2. Dans PaymentMethod, ajoutez fee qui reçoit un montant et renvoie 0 — une valeur par défaut pour tous.
  3. Dans CreditCard, redéfinissez fee : des frais de 2 % du montant.
  4. Mettez à jour checkout pour qu’il renvoie aussi les frais, comme dans la sortie.

Code de test :

for method in [CreditCard(), PayPal(), ApplePay()]:
    print(checkout(method, 100))

La sortie souhaitée :

Charging card 100 | fee: 2.0
Redirecting to PayPal 100 | fee: 0
Paid with Apple Pay 100 | fee: 0
Afficher la solution
class PaymentMethod:
    def pay(self, amount):
        raise NotImplementedError("subclasses must implement pay()")

    def fee(self, amount):
        return 0


class CreditCard(PaymentMethod):
    FEE_PERCENT = 2

    def pay(self, amount):
        return "Charging card " + str(amount)

    def fee(self, amount):
        return amount * CreditCard.FEE_PERCENT / 100


class PayPal(PaymentMethod):
    def pay(self, amount):
        return "Redirecting to PayPal " + str(amount)


class ApplePay(PaymentMethod):
    def pay(self, amount):
        return "Paid with Apple Pay " + str(amount)


def checkout(method, amount):
    return method.pay(amount) + " | fee: " + str(method.fee(amount))


for method in [CreditCard(), PayPal(), ApplePay()]:
    print(checkout(method, 100))
Charging card 100 | fee: 2.0
Redirecting to PayPal 100 | fee: 0
Paid with Apple Pay 100 | fee: 0

ApplePay a été ajouté sans changer une seule ligne de checkout. PayPal et ApplePay héritent du fee du parent, et CreditCard le redéfinit.

Exercice 3: Des personnages dans un jeu

Ce que vous construisez : un combat entre des personnages d’un jeu — où chaque type de personnage a une puissance d’attaque différente.

Ce qu’il faut écrire :

  1. Une classe de base nommée Character, avec un nom et une santé, et attack_power qui lance NotImplementedError.
  2. Warrior — sa puissance d’attaque vaut toujours 15.
  3. Mage — sa puissance d’attaque vaut 10, plus 10 s’il a plus de 50 de santé.
  4. Une fonction nommée attack qui reçoit un attaquant et une cible : elle retire de la santé à la cible selon la puissance de l’attaquant, et affiche une phrase comme dans la sortie.

Code de test :

conan = Warrior("Conan", 100)
merlin = Mage("Merlin", 60)
attack(conan, merlin)
attack(merlin, conan)
print(merlin.name, merlin.health)
print(conan.name, conan.health)

La sortie souhaitée :

Conan hits Merlin for 15
Merlin hits Conan for 10
Merlin 45
Conan 90
Afficher la solution
class Character:
    def __init__(self, name, health):
        self.name = name
        self.health = health

    def attack_power(self):
        raise NotImplementedError("subclasses must implement attack_power()")


class Warrior(Character):
    def attack_power(self):
        return 15


class Mage(Character):
    STRONG_FROM = 50

    def attack_power(self):
        if self.health > Mage.STRONG_FROM:
            return 20
        return 10


def attack(attacker, target):
    power = attacker.attack_power()
    target.health = target.health - power
    print(attacker.name, "hits", target.name, "for", power)


conan = Warrior("Conan", 100)
merlin = Mage("Merlin", 60)
attack(conan, merlin)
attack(merlin, conan)
print(merlin.name, merlin.health)
print(conan.name, conan.health)
Conan hits Merlin for 15
Merlin hits Conan for 10
Merlin 45
Conan 90

Après l’attaque de Conan, il ne restait à Merlin que 45 de santé — moins de 50 — sa puissance est donc tombée à 10. attack ne sait rien des règles de chaque personnage.

Exercice 4: Le Duck Typing

Ce que vous faites : lire du code et expliquer — sans écrire de code.

Le code :

class Duck:
    def speak(self):
        return "Quack"

class Robot:
    def speak(self):
        return "Beep"

def make_it_speak(thing):
    return thing.speak()

La tâche :

  1. Duck et Robot n’héritent pas du même parent. make_it_speak fonctionnera-t-elle avec les deux ? Pourquoi ?
  2. Expliquez en une phrase ce qu’est le Duck Typing.
Afficher la solution
  1. Oui. make_it_speak appelle seulement speak() — elle ne vérifie pas le type. Tout objet qui a speak convient.
  2. Le Duck Typing : « si ça marche comme un canard et que ça cancane comme un canard — c’est un canard ». En Python, ce qui compte, c’est que la méthode existe, et non de quelle classe vient l’objet. Une classe de base commune reste utile — pour définir l’interface à un seul endroit et donner un comportement par défaut — mais elle n’est pas obligatoire.

Exercice 5: NotImplementedError

Ce que vous construisez : des formes qui savent chacune calculer leur propre aire — et un parent qui l’exige.

Le code :

class Shape:
    def area(self):
        raise NotImplementedError("subclasses must implement area()")

class Square(Shape):
    def __init__(self, side):
        self.side = side

La tâche :

  1. Que se passera-t-il quand vous appellerez l’aire de Square(3) ? Pourquoi est-ce en fait une bonne chose ?
  2. Corrigez Square pour qu’il calcule une aire.
  3. Ajoutez Circle qui reçoit un rayon, et calcule l’aire avec math.pi, arrondie à deux chiffres.
  4. Affichez dans une boucle l’aire d’un carré de côté 3 et d’un cercle de rayon 3.

La sortie souhaitée :

9
28.27
Afficher la solution
  1. Une NotImplementedError: subclasses must implement area() sera lancée. C’est une bonne chose : l’erreur est découverte immédiatement et clairement — celui qui a écrit Square a oublié d’implémenter area. Sans elle, un résultat faux sortirait silencieusement.
import math


class Shape:
    def area(self):
        raise NotImplementedError("subclasses must implement area()")


class Square(Shape):
    def __init__(self, side):
        self.side = side

    def area(self):
        return self.side * self.side


class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return round(math.pi * self.radius ** 2, 2)


for shape in [Square(3), Circle(3)]:
    print(shape.area())
9
28.27

Résumé

  • Le polymorphisme = le même appel, un comportement différent selon le type de l’objet. Le code appelant n’a pas besoin de connaître le type.
  • Construisez une interface commune dans le parent (avec raise NotImplementedError pour les méthodes obligatoires), et chaque enfant l’implémente à sa façon.
  • Il remplace les chaînes de if isinstance / if type ==. Un nouveau type = une nouvelle classe, sans modifier le code existant.
  • Une méthode du parent qui appelle self.x() atteint la version de l’enfant (Template Method).
  • Le Duck Typing : en Python, il suffit que l’objet ait la méthode. Un héritage commun reste recommandé comme documentation.
  • Toutes les implémentations reçoivent les mêmes paramètres.

Documentation officielle