La responsabilité unique en POO et la composition face à l'héritage

Objectifs de la leçon
  • Reconnaître une classe qui a plus d’une responsabilité, et la découper
  • Expliquer ce qu’est la composition et construire un objet fait d’autres objets
  • Distinguer has-a et is-a et choisir entre composition et héritage
  • Comprendre pourquoi « Composition over Inheritance » est une règle empirique reconnue

Qu’allons-nous apprendre ?

  • Le principe de responsabilité unique (SRP) au niveau d’une classe.
  • La composition (Composition) : un objet fait d’autres objets.
  • La composition face à l’héritage : comment choisir.

Pourquoi en avons-nous besoin ?

Dans le thème du code propre, nous avons appris le SRP pour les fonctions : chaque fonction fait une seule chose. Exactement le même principe s’applique aux classes, et il y est encore plus important, car une grosse classe est un « tas » de données et de comportements. Quand chaque modification (du format du ticket, du moyen de paiement, du calcul de la TVA) touche la même classe, elle devient fragile et effrayante à modifier.

Et dans la leçon sur l’héritage, nous avons vu qu’une voiture ne doit pas hériter d’un moteur. Aujourd’hui, nous allons voir ce qu’il faut faire.

Explication à partir des bases

Le SRP au niveau d’une classe

Chaque classe doit avoir une seule raison de changer (Responsabilité = « une raison de changement »).

Comment reconnaître une classe qui a trop de responsabilités ?

  1. Il est difficile de la décrire en une phrase sans « et aussi ».
  2. Elle a des groupes d’attributs dont chaque groupe n’est utilisé que par une partie des méthodes.
  3. Elle change pour des raisons différentes : un changement dans la présentation du ticket, un changement dans les règles fiscales, et un changement dans l’envoi des e-mails.
  4. Son nom est trop général : Manager, Handler, Processor, Utils, System.

La composition — « a un » (has-a)

La composition = construire un objet complexe à partir d’autres objets, chacun responsable de sa partie. L’objet complexe les contient comme attributs, et leur délègue le travail.

class Car:
    def __init__(self):
        self.engine = Engine()       # the car HAS an engine
        self.gps = GPS()             # the car HAS a GPS

    def start(self):
        return self.engine.start()   # delegate the work to the part that knows how
Analogie : un ordinateur

Un ordinateur est **fait d’**un processeur, de mémoire, d’un disque et d’une carte graphique (souvenez-vous du thème sur l’ordinateur et le système d’exploitation !). L’ordinateur n’« hérite » pas du processeur. Il a un processeur. Chaque composant est responsable d’un seul rôle, et on peut remplacer un composant (un disque plus grand) sans changer le reste. C’est la composition.

La composition face à l’héritage

HéritageComposition
Relationis-a (« est une sorte de »)has-a (« a un »)
ExempleManager(Employee)Car contient un Engine
CouplageFort. L’enfant dépend de tous les détails du parentFaible. On ne communique qu’à travers l’interface de la partie
SouplesseDécidée quand la classe est écriteOn peut remplacer une partie à l’exécution
Quand quelque chose change dans le parent/la partieCela peut casser tous les enfantsCela n’affecte que ceux qui utilisent l’interface

Une règle empirique reconnue dans le secteur : « Favor Composition over Inheritance » — préférez la composition. N’utilisez l’héritage que lorsqu’il y a une vraie relation is-a, et quand vous voulez du polymorphisme.

Le test simple :

  • « X est une sorte de Y » ? → héritage.
  • « X a un Y » ? / « X utilise un Y » ? → composition.

Syntaxe

class Part:
    def do_work(self):
        return "part did its job"

class Whole:
    def __init__(self, part):
        self.part = part                 # composition: hold a part object

    def work(self):
        return self.part.do_work()       # delegation: let the part do it

whole = Whole(Part())                    # the part is passed in (can be swapped)

Quand la partie est passée en paramètre (et non créée à l’intérieur), on peut passer différentes parties, et c’est là que la composition et le polymorphisme se rencontrent.

Premier exemple de code

Une God Class avant le découpage :

class ShopSystem:
    def __init__(self):
        self.items = []
        self.vat = 0.17
        self.customer_email = ""

    def add_item(self, name, price, quantity):
        self.items.append((name, price, quantity))

    def subtotal(self):
        return sum(price * quantity for name, price, quantity in self.items)

    def total_with_vat(self):
        return round(self.subtotal() * (1 + self.vat), 2)

    def receipt_text(self):
        lines = []
        for name, price, quantity in self.items:
            lines.append(name + " x" + str(quantity) + " = " + str(price * quantity))
        lines.append("TOTAL: " + str(self.total_with_vat()))
        return "\n".join(lines)

    def send_receipt(self):
        print("Sending email to", self.customer_email)
        print(self.receipt_text())

Combien de raisons de changement y a-t-il ici ? (1) Comment les articles du panier sont gérés. (2) Comment la taxe est calculée. (3) À quoi ressemble un ticket. (4) Comment il est envoyé. Quatre responsabilités dans une seule classe.

Après le découpage avec la composition. week-3/shop_composition.py :

class Cart:
    def __init__(self):
        self._items = []

    def add(self, name, price, quantity=1):
        if price < 0 or quantity <= 0:
            raise ValueError("invalid price or quantity")
        self._items.append((name, price, quantity))

    def items(self):
        return list(self._items)

    def subtotal(self):
        return sum(price * quantity for name, price, quantity in self._items)

class TaxCalculator:
    def __init__(self, rate):
        self.rate = rate

    def total(self, amount):
        return round(amount * (1 + self.rate), 2)

class ReceiptFormatter:
    def format(self, cart, total):
        lines = []
        for name, price, quantity in cart.items():
            lines.append(name + " x" + str(quantity) + " = " + str(price * quantity))
        lines.append("TOTAL: " + str(total))
        return "\n".join(lines)

class Checkout:
    def __init__(self, cart, tax, formatter):
        self.cart = cart
        self.tax = tax
        self.formatter = formatter

    def receipt(self):
        total = self.tax.total(self.cart.subtotal())
        return self.formatter.format(self.cart, total)

cart = Cart()
cart.add("Bread", 8, 2)
cart.add("Cheese", 32)

checkout = Checkout(cart, TaxCalculator(0.17), ReceiptFormatter())
print(checkout.receipt())

Sortie :

Bread x2 = 16
Cheese x1 = 32
TOTAL: 56.16

Analyse du code ligne par ligne

ClasseUne seule responsabilitéUne seule raison de changement
CartLa gestion des articlesLes règles du panier (quantités, limites)
TaxCalculatorLe calcul de la taxeUne modification de la loi fiscale
ReceiptFormatterLa mise en forme d’un ticket en texteUne modification de la présentation du ticket
CheckoutRelier les partiesUne modification du processus de paiement
  • Checkout est **fait d’**un panier, d’un calculateur de taxe et d’un formateur (la composition), et leur délègue le travail : self.tax.total(...), self.formatter.format(...). Il ne calcule presque rien lui-même.
  • Chaque classe est petite, claire, et peut être testée seule : TaxCalculator(0.17).total(100) → 117.0.

Un autre exemple

La force de la composition : remplacer des parties (composition + polymorphisme). Nous ajoutons un autre formateur de ticket, et un calculateur de taxe pour une entreprise exonérée. Checkout ne change pas du tout.

class HtmlReceiptFormatter:
    def format(self, cart, total):
        rows = ""
        for name, price, quantity in cart.items():
            rows = rows + "<li>" + name + " x" + str(quantity) + "</li>"
        return "<ul>" + rows + "</ul><b>Total: " + str(total) + "</b>"

class TaxExempt:
    def total(self, amount):
        return amount

html_checkout = Checkout(cart, TaxCalculator(0.17), HtmlReceiptFormatter())
print(html_checkout.receipt())

exempt_checkout = Checkout(cart, TaxExempt(), ReceiptFormatter())
print(exempt_checkout.receipt())

Sortie :

<ul><li>Bread x2</li><li>Cheese x1</li></ul><b>Total: 56.16</b>
Bread x2 = 16
Cheese x1 = 32
TOTAL: 48

Comparez avec la version God Class : pour prendre en charge un ticket HTML et une entreprise exonérée de taxe, il aurait fallu ajouter des if dans receipt_text et total_with_vat. Ici, chaque option est une partie que l’on échange.

La composition au lieu de l’héritage — l’exemple classique

class Engine:
    def __init__(self, horsepower):
        self.horsepower = horsepower
        self.running = False

    def start(self):
        self.running = True
        return "Engine started (" + str(self.horsepower) + " hp)"

class Car:
    def __init__(self, model, engine):
        self.model = model
        self.engine = engine

    def start(self):
        return self.model + ": " + self.engine.start()

    def upgrade_engine(self, new_engine):
        self.engine = new_engine

car = Car("Corolla", Engine(130))
print(car.start())
car.upgrade_engine(Engine(180))
print(car.start())
Corolla: Engine started (130 hp)
Corolla: Engine started (180 hp)

Avec l’héritage (class Car(Engine)), on ne peut pas « remplacer le moteur ». La voiture est le moteur. Avec la composition, on remplace une partie à l’exécution.

Erreur courante

Erreur 1 : hériter pour « obtenir des méthodes utiles ».

class Order(list):
    pass

order = Order()
order.append("pizza")
order.sort()
order.clear()        # an order that can be "cleared", "reversed", "popped"...?

Order a reçu toutes les 40 méthodes et plus de list, y compris celles qui n’ont aucun sens pour une commande, et on ne peut pas contrôler les règles. La bonne façon : la composition, self._items = [], et n’exposer que ce dont une commande a besoin (add, items).

Erreur 2 : une composition « exposée » — contourner l’objet complexe.

checkout.cart._items.append(("Free TV", -500, 1))

Un chaînage profond qui touche l’intérieur d’une partie contourne ses règles. Communiquez avec une partie uniquement par ses méthodes publiques (checkout.cart.add(...)).

Erreur 3 : le découpage excessif. 15 classes de 3 lignes pour un problème qui se résout avec 2 classes claires. Le SRP dit « une seule raison de changement », pas « une seule méthode par classe ». Si deux actions changent toujours ensemble, elles constituent probablement une seule responsabilité.

Erreur 4 : une « composition » où la partie se crée elle-même à l’intérieur, toujours la même. self.tax = TaxCalculator(0.17) dans __init__ fonctionne, mais alors on ne peut pas la remplacer. Passer la partie en paramètre (Checkout(cart, tax, formatter)) donne la souplesse.

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 : une seule responsabilité par classe (SRP), la composition — une classe qui contient d’autres objets, remplacer une partie sans changer le reste, et quand ne pas utiliser l’héritage.

À la fin de la page, vous saurez :

  • Reconnaître une classe qui en fait trop et la découper
  • Construire une classe faite d’autres objets (has-a)
  • Remplacer un composant d’un objet sans changer le code qui l’utilise
  • Distinguer « est une sorte de » (héritage) et « a un » (composition)

Exercice 1: Une classe qui fait tout

Ce que vous faites : de la conception uniquement — reconnaître une classe qui en fait trop, et proposer un découpage.

Le code :

class ShopSystem:
    def add_item(self, name, price, quantity): ...
    def subtotal(self): ...
    def add_vat(self, amount): ...
    def format_receipt(self): ...
    def send_email(self, address): ...

La tâche :

  1. Combien de responsabilités différentes y a-t-il ici ? Écrivez chacune en mots.
  2. Proposez un découpage en plusieurs classes : un nom pour chaque classe, et quelles méthodes y passent.

Ce qu’il faut rendre : la liste des responsabilités, et un tableau des classes et des méthodes.

Afficher la solution
  1. Quatre responsabilités : gérer le panier (articles et total), calculer la taxe, mettre en forme un ticket, envoyer un e-mail. Chacune est une « raison de changer » différente — c’est une God Class.
  2. Un découpage :
ClasseResponsabilitéMéthodes
CartLes articlesadd_item, subtotal
VatCalculatorLa taxeadd_vat
ReceiptFormatterLa mise en formeformat_receipt
EmailSenderL’envoisend_email

Exercice 2: La composition — le panier et la caisse

Ce que vous construisez : un panier d’achats et une caisse — où la caisse est faite d’un panier et d’un calculateur de taxe.

Ce qu’il faut écrire :

  1. Cart — avec add qui reçoit un nom, un prix et une quantité (1 par défaut), et subtotal qui renvoie le total du panier.
  2. IsraeliVat — avec apply qui reçoit un montant et le renvoie TVA de 17 % comprise.
  3. Checkout — reçoit un panier et un calculateur de taxe dans __init__ et les contient (has-a).
  4. Dans Checkout : total — le total du panier, après le calculateur de taxe.

Code de test :

cart = Cart()
cart.add("Pen", 5, 4)
cart.add("Bag", 60)
checkout = Checkout(cart, IsraeliVat())
print(checkout.total())

La sortie souhaitée :

93.6
Afficher la solution
class Cart:
    def __init__(self):
        self._items = []

    def add(self, name, price, quantity=1):
        self._items.append((name, price, quantity))

    def subtotal(self):
        total = 0
        for name, price, quantity in self._items:
            total = total + price * quantity
        return total


class IsraeliVat:
    RATE = 0.17

    def apply(self, amount):
        return round(amount * (1 + IsraeliVat.RATE), 2)


class Checkout:
    def __init__(self, cart, tax):
        self.cart = cart
        self.tax = tax

    def total(self):
        return self.tax.apply(self.cart.subtotal())


cart = Cart()
cart.add("Pen", 5, 4)
cart.add("Bag", 60)
checkout = Checkout(cart, IsraeliVat())
print(checkout.total())
93.6

Checkout ne calcule rien lui-même — il délègue (Delegation) au panier et au calculateur de taxe, chacun expert d’une seule chose.

Exercice 3: Remplacer une partie

Ce que vous construisez : la suite de la caisse — remplacer le calculateur de taxe sans modifier Checkout.

Ce qu’il faut écrire :

  1. Une nouvelle classe nommée TaxExempt, avec apply qui renvoie le montant inchangé.
  2. Créez une caisse avec IsraeliVat et une seconde caisse avec TaxExempt, sur le même panier, et affichez le total des deux.
  3. Vérifiez : vous n’avez pas modifié une seule ligne de Checkout.

La sortie souhaitée :

93.6
80
Afficher la solution
class Cart:
    def __init__(self):
        self._items = []

    def add(self, name, price, quantity=1):
        self._items.append((name, price, quantity))

    def subtotal(self):
        total = 0
        for name, price, quantity in self._items:
            total = total + price * quantity
        return total


class IsraeliVat:
    RATE = 0.17

    def apply(self, amount):
        return round(amount * (1 + IsraeliVat.RATE), 2)


class TaxExempt:
    def apply(self, amount):
        return amount


class Checkout:
    def __init__(self, cart, tax):
        self.cart = cart
        self.tax = tax

    def total(self):
        return self.tax.apply(self.cart.subtotal())


cart = Cart()
cart.add("Pen", 5, 4)
cart.add("Bag", 60)
print(Checkout(cart, IsraeliVat()).total())
print(Checkout(cart, TaxExempt()).total())
93.6
80

C’est la force de la composition + le polymorphisme : chaque calculateur de taxe implémente apply, et la caisse fonctionne avec n’importe lequel.

Exercice 4: Hériter pour économiser du code ?

Ce que vous faites : reconnaître un mauvais héritage, et le corriger avec la composition.

Le code :

class Engine:
    def start(self):
        return "engine started"

class Car(Engine):
    def __init__(self, model):
        self.model = model

La tâche :

  1. Pourquoi l’héritage est-il faux ici, alors que le code fonctionne ? Utilisez le test « est une sorte de ».
  2. Réécrivez : Engine reçoit une puissance, et Car reçoit un modèle et un moteur et le contient (has-a). Car a un start qui utilise le moteur.

Code de test :

car = Car("Mazda", Engine(150))
print(car.start())

La sortie souhaitée :

Mazda: engine 150hp started
Afficher la solution
  1. Une voiture n’est pas une sorte de moteur — une voiture a un moteur. On a hérité seulement pour « obtenir » start, et maintenant Car contient tout ce qu’a un moteur, et on ne peut pas remplacer le moteur.
  2. has-a :
class Engine:
    def __init__(self, horsepower):
        self.horsepower = horsepower

    def start(self):
        return "engine " + str(self.horsepower) + "hp started"


class Car:
    def __init__(self, model, engine):
        self.model = model
        self.engine = engine

    def start(self):
        return self.model + ": " + self.engine.start()


car = Car("Mazda", Engine(150))
print(car.start())
Mazda: engine 150hp started

Exercice 5: Un ordinateur fait de pièces

Ce que vous construisez : un ordinateur fait d’un processeur, de mémoire et d’un disque.

Ce qu’il faut écrire :

  1. CPU — avec un nombre de cœurs et une vitesse en GHz.
  2. RAM — avec une taille en Go.
  3. Disk — avec une taille en Go.
  4. Computer — reçoit les trois pièces et les contient. Il a un __str__ qui décrit l’ordinateur comme dans la sortie, et can_run qui reçoit un minimum de cœurs et un minimum de mémoire et renvoie True ou False.

Code de test :

pc = Computer(CPU(8, 3.2), RAM(8), Disk(512))
print(pc)
print("Can run the game?", pc.can_run(6, 16))

La sortie souhaitée :

8-core 3.2GHz CPU, 8GB RAM, 512GB SSD
Can run the game? False
Afficher la solution
class CPU:
    def __init__(self, cores, ghz):
        self.cores = cores
        self.ghz = ghz

    def __str__(self):
        return str(self.cores) + "-core " + str(self.ghz) + "GHz CPU"


class RAM:
    def __init__(self, gb):
        self.gb = gb

    def __str__(self):
        return str(self.gb) + "GB RAM"


class Disk:
    def __init__(self, gb):
        self.gb = gb

    def __str__(self):
        return str(self.gb) + "GB SSD"


class Computer:
    def __init__(self, cpu, ram, disk):
        self.cpu = cpu
        self.ram = ram
        self.disk = disk

    def can_run(self, min_cores, min_ram):
        return self.cpu.cores >= min_cores and self.ram.gb >= min_ram

    def __str__(self):
        return str(self.cpu) + ", " + str(self.ram) + ", " + str(self.disk)


pc = Computer(CPU(8, 3.2), RAM(8), Disk(512))
print(pc)
print("Can run the game?", pc.can_run(6, 16))
8-core 3.2GHz CPU, 8GB RAM, 512GB SSD
Can run the game? False

Il y a assez de cœurs (8 ≥ 6), mais seulement 8 Go de RAM sur les 16 demandés. Chaque pièce sait se présenter, et Computer ne fait que les relier.

Résumé

  • Le SRP dans une classe : une seule raison de changer. Une God Class (...Manager, ...System) → découpez-la.
  • La composition = un objet fait d’autres objets (has-a), qui leur délègue le travail.
  • L’héritage seulement pour un vrai is-a. Pour has-a ou « utilise un » → la composition.
  • Favor Composition over Inheritance : un couplage faible, et des parties remplaçables.
  • Passez les parties en paramètres à __init__, pour pouvoir les remplacer (composition + polymorphisme).
  • Communiquez avec une partie par son interface, pas par son intérieur.

Documentation officielle