Aller au contenu
>_ developpeur-python
Tous les articles
ArchitecturePOOPythonDesign Pattern

Composition ou héritage en POO : avantages, inconvénients et quand choisir

Sébastien Mizrahi ,
Composition ou héritage en POO : avantages, inconvénients et quand choisir

En programmation orientée objet, l'héritage et la composition sont deux façons de réutiliser du code et de construire des objets à partir d'autres. L'héritage exprime une relation "est un" : un chien est un animal. La composition exprime une relation "a un" : une voiture a un moteur. La règle que je retiens, et que je vais nuancer ici, tient en une phrase souvent citée : privilégier la composition, et réserver l'héritage aux vraies relations "est un". Voici pourquoi, avec les avantages et les limites de chacun, une démonstration en Python et un tableau comparatif.

Composition et héritage : est un contre a un L'héritage relie une sous-classe à sa classe parente ("est un"). La composition assemble un objet à partir d'autres ("a un").

L'héritage : la relation "est un"

Avec l'héritage, une classe dérive d'une classe parente et récupère ses attributs et ses méthodes. On spécialise ensuite le comportement dans la sous-classe.

class Animal:
    def __init__(self, name: str) -> None:
        self.name = name

    def describe(self) -> str:
        return f"{self.name} is an animal"

class Dog(Animal):
    def speak(self) -> str:
        return "Woof"

Dog hérite de describe sans le réécrire, et ajoute speak. C'est concis et cela donne le polymorphisme gratuitement : partout où un Animal est attendu, un Dog fait l'affaire.

Les avantages sont la réutilisation immédiate du code de la classe parente, le polymorphisme, et une taxonomie lisible quand le domaine s'y prête vraiment. Les inconvénients apparaissent vite. L'héritage crée un couplage fort entre parent et enfant : un changement dans la classe parente peut casser les sous-classes sans prévenir, c'est le problème de la classe de base fragile. La hiérarchie est rigide, fixée à l'écriture du code, et l'enfant voit les détails internes du parent, ce qui affaiblit l'encapsulation. Surtout, dès qu'un objet doit combiner plusieurs comportements, la hiérarchie explose en un enchevêtrement de sous-classes.

La composition : la relation "a un"

Avec la composition, un objet contient d'autres objets et leur délègue une partie du travail.

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

class Car:
    def __init__(self) -> None:
        self.engine = Engine()  # Car has-a Engine

    def start(self) -> str:
        return self.engine.start()

Car n'est pas un Engine, elle en possède un et lui délègue le démarrage. Les avantages sont un couplage faible (on peut remplacer le moteur par un autre sans toucher à Car), une flexibilité au moment de l'exécution, la possibilité de combiner librement des comportements, et une bien meilleure testabilité, puisqu'on peut injecter un composant simulé. Les inconvénients sont un peu plus de code de délégation, une indirection supplémentaire, et le fait que le polymorphisme n'est pas automatique : il faut s'appuyer sur des interfaces, en Python des classes abstraites ou des Protocol.

Démonstration : quand l'héritage coince

Prenons un besoin qui grossit. On modélise des véhicules qui se déplacent. En héritage, on part bien.

class Vehicle:
    def move(self) -> str:
        raise NotImplementedError

class Car(Vehicle):
    def move(self) -> str:
        return "driving on the road"

class Boat(Vehicle):
    def move(self) -> str:
        return "sailing on water"

Jusque-là tout va bien. Arrive un véhicule amphibie, qui roule et navigue. Avec l'héritage, on est coincé : hériter à la fois de Car et de Boat mène à une ambiguïté sur move, et si demain il faut aussi voler, on repart dans une nouvelle combinaison de classes. Chaque croisement de comportements demande une classe de plus.

La composition règle le problème en traitant chaque capacité comme un composant que l'on assemble.

class RoadMovement:
    def move(self) -> str:
        return "driving on the road"

class WaterMovement:
    def move(self) -> str:
        return "sailing on water"

class Vehicle:
    def __init__(self, movements: list) -> None:
        self._movements = movements

    def move(self) -> list[str]:
        return [movement.move() for movement in self._movements]

car = Vehicle([RoadMovement()])
boat = Vehicle([WaterMovement()])
amphibious = Vehicle([RoadMovement(), WaterMovement()])

L'amphibie n'est plus une classe de plus, c'est simplement un véhicule qui combine deux capacités. On peut même changer ses comportements à l'exécution. C'est, au passage, l'idée du patron Strategy : encapsuler un comportement variable dans un objet dédié plutôt que de le figer dans une hiérarchie.

Un bon usage de l'héritage : isoler une librairie derrière une interface

Tout ceci ne condamne pas l'héritage, au contraire. Un cas où je m'en sers volontiers : encapsuler une librairie externe derrière une interface. Je définis une interface abstraite qui décrit le service dont j'ai besoin, puis un adaptateur par librairie qui hérite de cette interface et enveloppe la librairie concrète.

from abc import ABC, abstractmethod

class Cache(ABC):
    """Stable interface the rest of the app depends on."""

    @abstractmethod
    def get(self, key: str) -> str | None: ...

    @abstractmethod
    def set(self, key: str, value: str) -> None: ...

class RedisCache(Cache):
    def __init__(self, client) -> None:
        self._client = client  # wraps the third-party library

    def get(self, key: str) -> str | None:
        return self._client.get(key)

    def set(self, key: str, value: str) -> None:
        self._client.set(key, value)

Le reste de l'application ne dépend que de Cache, jamais de la librairie directement. Le jour où je change de librairie, j'écris un nouvel adaptateur, par exemple MemcachedCache(Cache), qui implémente la même interface, et rien d'autre ne bouge. C'est le patron Adapter, et c'est aussi l'esprit de l'architecture hexagonale : la dépendance externe est confinée derrière une frontière stable, ce qui permet de changer de librairie sans propager le changement dans tout le code.

Remarquez que les deux idées se combinent ici : l'héritage porte le contrat (l'adaptateur est un Cache) et la composition enveloppe la librairie (l'adaptateur a un client). C'est souvent ensemble qu'elles donnent le meilleur résultat.

Tableau comparatif

CritèreHéritageComposition
Relation exprimée"est un""a un"
CouplageFort (parent et enfant liés)Faible (via une interface)
Flexibilité à l'exécutionFigée à la compilationLes composants sont interchangeables
Combinaison de comportementsExplosion de sous-classesAssemblage simple
EncapsulationAffaiblie (l'enfant voit le parent)Préservée
TestabilitéPlus difficile à isolerFacile (injection de composants)
PolymorphismeAutomatiqueVia interfaces ou Protocol
Risque principalClasse de base fragileUn peu plus de code de délégation

Quand utiliser l'un ou l'autre

Je choisis l'héritage quand la relation "est un" est réelle et stable, quand la hiérarchie reste peu profonde, et quand le principe de substitution de Liskov tient : une sous-classe doit pouvoir remplacer sa parente partout, sans surprise. Les points d'extension d'un framework, où l'on hérite d'une classe de base prévue pour ça, sont un bon cas, tout comme le fait d'hériter d'une interface abstraite pour isoler une librairie derrière une frontière stable, ainsi que je l'ai montré plus haut.

Je choisis la composition, et c'est mon réglage par défaut, dès qu'un comportement varie ou se combine, quand je veux pouvoir échanger une brique sans casser le reste, ou simplement pour éviter les hiérarchies profondes qui deviennent vite illisibles. En Python, on garde le polymorphisme avec typing.Protocol ou des classes abstraites, sans imposer d'arbre d'héritage. Les mixins existent comme voie intermédiaire, mais je les emploie avec parcimonie, car ils ramènent une partie des inconvénients de l'héritage.

Ce genre d'arbitrage rejoint d'autres décisions d'architecture, comme le choix d'un bus d'événements pour découpler des modules.

Ce qu'il faut retenir

L'héritage et la composition ne s'opposent pas frontalement, ils répondent à des besoins différents. L'héritage brille sur une vraie relation "est un", stable et peu profonde. La composition, plus souple et moins couplée, gère mieux les comportements qui varient et se combinent, ce qui couvre la majorité des cas réels. En cas de doute, je commence par la composition et je ne passe à l'héritage que lorsque la relation "est un" s'impose d'elle-même.

Vous travaillez sur la conception d'un système Python et hésitez sur la bonne structure ? Voyez mes expertises en architecture ou parlons-en.

Un projet Python ou IA à concrétiser ?

Discutons de votre besoin.

Me contacter