La responsabilité unique en POO et la composition face à l'héritage
- 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 ?
- Il est difficile de la décrire en une phrase sans « et aussi ».
- Elle a des groupes d’attributs dont chaque groupe n’est utilisé que par une partie des méthodes.
- 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.
- 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 howUn 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éritage | Composition | |
|---|---|---|
| Relation | is-a (« est une sorte de ») | has-a (« a un ») |
| Exemple | Manager(Employee) | Car contient un Engine |
| Couplage | Fort. L’enfant dépend de tous les détails du parent | Faible. On ne communique qu’à travers l’interface de la partie |
| Souplesse | Décidée quand la classe est écrite | On peut remplacer une partie à l’exécution |
| Quand quelque chose change dans le parent/la partie | Cela peut casser tous les enfants | Cela 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.16Analyse du code ligne par ligne
| Classe | Une seule responsabilité | Une seule raison de changement |
|---|---|---|
Cart | La gestion des articles | Les règles du panier (quantités, limites) |
TaxCalculator | Le calcul de la taxe | Une modification de la loi fiscale |
ReceiptFormatter | La mise en forme d’un ticket en texte | Une modification de la présentation du ticket |
Checkout | Relier les parties | Une modification du processus de paiement |
Checkoutest **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: 48Comparez 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 :
- Combien de responsabilités différentes y a-t-il ici ? Écrivez chacune en mots.
- 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
- 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.
- Un découpage :
| Classe | Responsabilité | Méthodes |
|---|---|---|
Cart | Les articles | add_item, subtotal |
VatCalculator | La taxe | add_vat |
ReceiptFormatter | La mise en forme | format_receipt |
EmailSender | L’envoi | send_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 :
Cart— avecaddqui reçoit un nom, un prix et une quantité (1 par défaut), etsubtotalqui renvoie le total du panier.IsraeliVat— avecapplyqui reçoit un montant et le renvoie TVA de 17 % comprise.Checkout— reçoit un panier et un calculateur de taxe dans__init__et les contient (has-a).- 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.6Afficher 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.6Checkout 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 :
- Une nouvelle classe nommée
TaxExempt, avecapplyqui renvoie le montant inchangé. - Créez une caisse avec
IsraeliVatet une seconde caisse avecTaxExempt, sur le même panier, et affichez le total des deux. - Vérifiez : vous n’avez pas modifié une seule ligne de
Checkout.
La sortie souhaitée :
93.6
80Afficher 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
80C’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 = modelLa tâche :
- Pourquoi l’héritage est-il faux ici, alors que le code fonctionne ? Utilisez le test « est une sorte de ».
- Réécrivez :
Enginereçoit une puissance, etCarreçoit un modèle et un moteur et le contient (has-a).Cara unstartqui utilise le moteur.
Code de test :
car = Car("Mazda", Engine(150))
print(car.start())La sortie souhaitée :
Mazda: engine 150hp startedAfficher la solution
- Une voiture n’est pas une sorte de moteur — une voiture a un moteur. On a hérité seulement pour « obtenir »
start, et maintenantCarcontient tout ce qu’a un moteur, et on ne peut pas remplacer le moteur. - 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 startedExercice 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 :
CPU— avec un nombre de cœurs et une vitesse en GHz.RAM— avec une taille en Go.Disk— avec une taille en Go.Computer— reçoit les trois pièces et les contient. Il a un__str__qui décrit l’ordinateur comme dans la sortie, etcan_runqui reçoit un minimum de cœurs et un minimum de mémoire et renvoieTrueouFalse.
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? FalseAfficher 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? FalseIl 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.