Cours de Python / Choisir entre héritage et composition

L’héritage spécialise une classe existante ; la composition confie une partie du travail à un objet ou à une fonction fournie comme composant. Pour choisir, demandez-vous si vous avez besoin d’un véritable sous-type ou d’une politique remplaçable, plutôt que de vous appuyer uniquement sur la ressemblance du code.

Choisir selon la relation entre les responsabilités

Une politique de frais fixes peut être une forme particulière de politique de frais. Un registre, en revanche, utilise cette politique pour calculer un montant : il n’est pas lui-même une politique. L’héritage peut exprimer la première relation et la composition la seconde. Les deux mécanismes peuvent donc coopérer dans un même programme.

Un contrat décrit ce qu’un appelant peut fournir et ce qu’il obtient en retour : ici, une méthode apply reçoit un montant et renvoie le montant après application de la politique. Une sous-classe compatible conserve cette manière d’appeler la méthode et le sens du résultat, même si le calcul change. Le tutoriel officiel sur l’héritage Python explique la redéfinition des méthodes.

Ne créez pas une hiérarchie uniquement pour récupérer une méthode utile. Si une sous-classe exige des entrées que l’appelant de la classe de base n’avait pas à fournir, ou renvoie un résultat d’un autre sens, la substitution devient fragile. Des responsabilités distinctes, présentées dans le cours sur les classes et instances, aident à définir cette frontière.

Combiner une politique spécialisée et un registre composé

Enregistrez le programme complet dans composition.py, en UTF-8, dans un dossier d’exercice. La bibliothèque standard suffit. Dans un terminal ouvert dans ce dossier avec l’environnement virtuel du projet sélectionné, lancez python composition.py. Utilisez python3 composition.py si python3 est votre commande d’interpréteur hors environnement virtuel sous macOS ou Ubuntu.

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

class FixedFee(FeePolicy):
    def __init__(self, fee):
        self.fee = fee

    def apply(self, amount):
        return super().apply(amount) + self.fee

class Ledger:
    def __init__(self, policy):
        self.policy = policy

    def charge(self, amount):
        return self.policy.apply(amount)

print(Ledger(FeePolicy()).charge(8000))
print(Ledger(FixedFee(500)).charge(8000))

Résultat attendu :

8000
8500

FeePolicy renvoie le montant sans modification. FixedFee en hérite et redéfinit apply pour ajouter les frais stockés dans self.fee. Ledger conserve la politique reçue et lui délègue le calcul : charge appelle apply au lieu de connaître lui-même la formule des frais.

Dans cette hiérarchie simple, super().apply(amount) atteint l’implémentation de FeePolicy. Plus généralement, super suit l’ordre de résolution des méthodes, appelé MRO pour method resolution order : ce n’est pas toujours un synonyme de « classe mère nommée directement ». La documentation de super précise ce mécanisme, notamment pour l’héritage multiple. Ici, un seul héritage suffit à suivre le chemin du calcul.

Pour une comparaison contrôlée, remplacez seulement FixedFee(500) par FixedFee(0). Les deux lignes affichent alors 8000. Rétablissez 500 et vérifiez de nouveau 8000 puis 8500.

Comparer deux solutions au même besoin

Le programme suivant sépare les alternatives. Enregistrez-le dans un autre fichier, policies.py, puis lancez python policies.py depuis le même dossier, ou la commande python3 correspondante. Vous conservez ainsi le premier programme pour le comparer.

class InheritedLedger:
    def fee(self):
        return 0
    def charge(self, amount):
        return amount + self.fee()
class FixedLedger(InheritedLedger):
    def fee(self):
        return 500
class ComposedLedger:
    def __init__(self, fee_policy):
        self.fee_policy = fee_policy
    def charge(self, amount):
        return amount + self.fee_policy()
def fixed_fee():
    return 500
print(FixedLedger().charge(8000))
ledger = ComposedLedger(fixed_fee)
print(ledger.charge(8000))
ledger.fee_policy = lambda: 0
print(ledger.charge(8000))

Résultat attendu :

8500
8500
8000

Dans FixedLedger, l’héritage redéfinit fee. La méthode charge, héritée de InheritedLedger, appelle self.fee() : elle utilise donc l’implémentation de la sous-classe. Dans ComposedLedger, charge appelle une fonction reçue lors de l’initialisation. Le nom fixed_fee est passé sans parenthèses pour transmettre la fonction ; fixed_fee() transmettrait le nombre 500, que charge ne pourrait pas appeler.

La ligne ledger.fee_policy = lambda: 0 remplace le composant du registre existant par une fonction sans paramètre qui renvoie zéro. C’est pourquoi son second calcul donne 8000. Il n’a pas fallu redéfinir sa classe ni reconstruire cet objet. Une fonction nommée conviendrait également ; lambda sert ici à une expression très courte.

Les interfaces des deux fichiers ne sont pas identiques. Dans composition.py, le collaborateur fournit apply(amount), qui renvoie le total. Dans policies.py, le collaborateur est appelable sans argument et renvoie seulement les frais à ajouter. Ne passez pas directement l’un à la place de l’autre sans adaptation.

Python n’exige pas que tous les collaborateurs héritent d’une même classe. Dans le premier fichier, un autre objet peut convenir s’il fournit une méthode apply compatible. Le nom seul ne suffit pas : la signature, le résultat et les erreurs possibles doivent répondre au contrat. Cette séparation permet aussi de fournir une petite politique de test au lieu d’un service externe.

Vérifier le contrat avant de généraliser

  • Substitution : l’appelant peut-il utiliser le sous-type avec les mêmes entrées et comprendre son résultat de la même manière ?
  • Changement : voulez-vous spécialiser durablement un type ou remplacer un collaborateur pendant l’exécution ?
  • Responsabilité : héritez-vous de comportements sans rapport pour réutiliser seulement quelques lignes ?
  • Simplicité : une fonction ou un argument explicite suffirait-il pour ce besoin limité ?

Dans policies.py, remplacez temporairement la valeur 500 renvoyée par FixedLedger.fee par 0, sans toucher à fixed_fee. Les lignes deviennent 8000, 8500, 8000 : les deux politiques sont bien indépendantes. Rétablissez 500, puis vérifiez le résultat original. En changeant une seule politique, vous pouvez attribuer la différence à une décision précise.

Ces programmes illustrent les relations entre objets, sans imposer de validation des montants ou des frais. Une valeur négative est donc calculée telle quelle si l’addition la permet. Si le domaine interdit certains montants, définissez et testez cette règle séparément. Gardez aussi la saisie utilisateur et les écritures de fichiers hors du calcul de la politique : comparer les résultats sera plus simple.

La composition facilite ici le remplacement de la règle ; elle demande en contrepartie de fournir un composant compatible. L’héritage rend la spécialisation visible dans le type, mais ajoute un lien avec les règles de la classe de base. Choisissez la structure dont les dépendances correspondent réellement aux changements attendus.