# La responsabilité unique en POO et la composition face à l'héritage<no value>

## 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.

```python
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
```

{{< box "analogy" "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.
{{< /box >}}

### 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

```python
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 :**

```python
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` :

```python
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 :**

```text
Bread x2 = 16
Cheese x1 = 32
TOTAL: 56.16
```

## Analyse 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 |

- `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.**

```python
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 :**

```text
<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

```python
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())
```

```text
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 ».**

```python
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.**

```python
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.

<!-- exercises:start — generated by scripts/intro6/sync_site_exercises.py; edit scripts/intro6/exercises/ -->
## 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 :**

```text
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.

{{< 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 :

| 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` |
{{< /solution >}}

### 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 :**

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

**La sortie souhaitée :**

```text
93.6
```

{{< solution >}}
```python
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())
```

```text
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.
{{< /solution >}}

### 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 :**

```text
93.6
80
```

{{< solution >}}
```python
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())
```

```text
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.
{{< /solution >}}

### 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 :**

```text
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 :**

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

**La sortie souhaitée :**

```text
Mazda: engine 150hp started
```

{{< 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** :

```python
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())
```

```text
Mazda: engine 150hp started
```
{{< /solution >}}

### 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 :**

```python
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 :**

```text
8-core 3.2GHz CPU, 8GB RAM, 512GB SSD
Can run the game? False
```

{{< solution >}}
```python
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))
```

```text
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.
{{< /solution >}}

<!-- exercises:end -->

## 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.

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

## Documentation officielle

- [Python Tutorial — Classes](https://docs.python.org/3/tutorial/classes.html)
- [Python Tutorial — Inheritance](https://docs.python.org/3/tutorial/classes.html#inheritance)

<!-- docs:end -->
