Agile Software Development
Chapitre 6 / 15 · 28 min de lecture

LSP, DIP & ISP : substitution, inversion et ségrégation

Trois principes SOLID : les sous-types doivent être substituables à leurs types de base, les modules de haut niveau ne doivent pas dépendre des détails, et les clients ne doivent pas dépendre de méthodes qu'ils n'utilisent pas.

Après le SRP et l'OCP, voici les trois derniers principes que Martin range sous l'acronyme SOLID. Le principe de substitution de Liskov (Liskov Substitution Principle, LSP) gouverne le bon usage de l'héritage et fournit à l'OCP son carburant : c'est la substituabilité des sous-types qui permet d'étendre un module sans le modifier. Le principe d'inversion des dépendances (Dependency-Inversion Principle, DIP) renverse la structure de dépendance habituelle pour que les politiques de haut niveau ne dépendent plus des détails de bas niveau. Le principe de ségrégation des interfaces (Interface-Segregation Principle, ISP) combat les interfaces « grasses » en les découpant selon les clients qui les utilisent. Le livre est écrit en C++ et en Java ; nous transposons ses exemples canoniques — le carré et le rectangle, l'ensemble persistant, le bouton et la lampe, le thermostat, la porte temporisée, l'interface de distributeur — en TypeScript idiomatique.

LSP : les sous-types doivent être substituables

Les deux mécanismes qui sous-tendent l'OCP sont l'abstraction et le polymorphisme. Dans les langages à typage statique, l'un des principaux supports de ces mécanismes est l'héritage : c'est par lui que nous créons des classes dérivées qui implémentent les méthodes abstraites d'une classe de base. Mais quelles sont les règles qui gouvernent ce bon usage de l'héritage ? Quelles sont les caractéristiques des meilleures hiérarchies ? Quels pièges nous font produire des hiérarchies qui ne respectent pas l'OCP ? C'est à ces questions que répond le principe de substitution de Liskov, énoncé pour la première fois par Barbara Liskov en 1988 :

Les sous-types doivent être substituables à leurs types de base.

L'importance de ce principe devient évidente dès que l'on considère les conséquences de sa violation. Supposons une fonction f qui prend pour argument une référence vers une classe de base B, et une dérivée D de B qui, passée à f sous le déguisement d'un B, fait dysfonctionner f. Alors D viole le LSP : elle est fragile en présence de f. Les auteurs de f seront tentés d'y ajouter un test de type pour D afin que f se comporte correctement — mais ce test viole l'OCP, puisque f n'est plus fermée à toutes les dérivées de B. De tels tests sont une odeur caractéristique d'une violation du LSP.

Une violation simple : le test de type explicite

Violer le LSP conduit fréquemment à l'usage d'informations de type à l'exécution (RTTI) d'une manière qui viole grossièrement l'OCP. Un if, ou une chaîne de if/else, sert alors à déterminer le type d'un objet pour sélectionner le comportement approprié. Imaginons une classe Shape dépourvue de toute méthode polymorphe, dont Square et Circle dérivent en exposant chacune sa propre méthode draw sans rien redéfinir.

// ❌ Square et Circle ne sont pas substituables à Shape.
type ShapeType = "square" | "circle";

class Shape {
  constructor(public readonly itsType: ShapeType) {}
}

class Square extends Shape {
  constructor() {
    super("square");
  }
  draw(): void {
    /* ... */
  }
}

class Circle extends Shape {
  constructor() {
    super("circle");
  }
  draw(): void {
    /* ... */
  }
}

function drawShape(s: Shape): void {
  if (s.itsType === "square") {
    (s as Square).draw();
  } else if (s.itsType === "circle") {
    (s as Circle).draw();
  }
}

La fonction drawShape viole manifestement l'OCP : elle doit connaître toutes les dérivées possibles de Shape et changer chaque fois qu'on en crée une nouvelle. Pourquoi un programmeur écrirait-il une telle horreur ? Parce qu'il a jugé le surcoût du polymorphisme trop élevé et défini Shape sans méthode virtuelle. Comme Square et Circle ne sont pas substituables à Shape, drawShape est forcée d'inspecter le type entrant. La violation du LSP a donc provoqué la violation de l'OCP : une violation du LSP est une violation latente de l'OCP.

Le carré et le rectangle : une violation plus subtile

Il existe des façons bien plus subtiles de violer le LSP. Considérons une application qui utilise une classe Rectangle. Elle fonctionne bien, est déployée sur de nombreux sites, et comme tout logiciel à succès, ses utilisateurs réclament des évolutions. Un jour, ils veulent manipuler des carrés en plus des rectangles.

On dit souvent que l'héritage est la relation EST-UN : si un nouvel objet peut être dit « EST-UN » objet ancien, alors la classe du nouveau doit dériver de celle de l'ancien. Or, à toutes fins ordinaires, un carré est un rectangle. Il paraît donc logique de voir Square dériver de Rectangle.

           Rectangle


            Square

Ce raisonnement passe pour une technique fondamentale de l'analyse orientée objet. Pourtant il mène à des problèmes subtils mais significatifs, qu'on ne voit généralement pas avant de les rencontrer dans le code. Premier indice : un Square n'a pas besoin de deux variables membres distinctes pour sa largeur et sa hauteur, et pourtant il en hérite — gaspillage qui, multiplié par des centaines de milliers d'instances, peut devenir significatif. Mais laissons l'efficacité mémoire de côté. Square hérite des méthodes setWidth et setHeight, inappropriées pour un carré dont la largeur et la hauteur sont identiques. On peut contourner le problème en les redéfinissant pour qu'elles maintiennent l'invariant du carré.

class Rectangle {
  protected itsWidth = 0;
  protected itsHeight = 0;

  setWidth(w: number): void {
    this.itsWidth = w;
  }
  setHeight(h: number): void {
    this.itsHeight = h;
  }
  getWidth(): number {
    return this.itsWidth;
  }
  getHeight(): number {
    return this.itsHeight;
  }
  area(): number {
    return this.itsWidth * this.itsHeight;
  }
}

class Square extends Rectangle {
  // Redéfinitions qui préservent l'invariant largeur === hauteur.
  override setWidth(w: number): void {
    super.setWidth(w);
    super.setHeight(w);
  }
  override setHeight(h: number): void {
    super.setHeight(h);
    super.setWidth(h);
  }
}

Désormais, fixer la largeur d'un Square ajuste sa hauteur, et inversement : l'invariant du carré reste intact, et l'objet demeure un carré mathématiquement correct.

const s = new Square();
s.setWidth(1); // fixe heureusement la hauteur à 1 aussi
s.setHeight(2); // fixe largeur et hauteur à 2, parfait

Le vrai problème

Square et Rectangle semblent maintenant fonctionner. Quoi que vous fassiez à un Square, il reste un carré ; quoi que vous fassiez à un Rectangle, il reste un rectangle ; et vous pouvez passer un Square à une fonction qui attend un Rectangle sans qu'il cesse de se comporter en carré cohérent. On pourrait conclure que la conception est désormais correcte. Ce serait une erreur. Une conception cohérente avec elle-même n'est pas nécessairement cohérente avec tous ses utilisateurs. Considérons la fonction suivante :

function g(r: Rectangle): void {
  r.setWidth(5);
  r.setHeight(4);
  console.assert(r.area() === 20);
}

Cette fonction marche parfaitement pour un Rectangle, mais déclenche une erreur d'assertion si on lui passe un Square. Voilà le vrai problème : l'auteur de g a supposé que changer la largeur d'un rectangle laisse sa hauteur inchangée. C'est une hypothèse parfaitement raisonnable — l'indépendance de la largeur et de la hauteur est un invariant que l'on peut légitimement attendre d'une classe nommée Rectangle. Mais tous les objets passables comme Rectangle ne satisfont pas cette hypothèse. La fonction g est fragile vis-à-vis de la hiérarchie Square/Rectangle.

Puisque, pour des fonctions comme g, Square n'est pas substituable à Rectangle, la relation entre les deux viole le LSP. On pourrait rétorquer que le problème vient de g, dont l'auteur n'avait pas le droit de supposer l'indépendance des dimensions. Mais c'est faux : l'invariant porte le nom même de Rectangle. Fait remarquable, l'auteur de Square n'a pas violé un invariant de Square — en faisant dériver Square de Rectangle, il a violé un invariant de Rectangle.

Note

La validité n'est pas intrinsèque. Le LSP conduit à une conclusion capitale : un modèle considéré isolément ne peut être validé de manière significative. La validité d'un modèle ne s'exprime qu'en fonction de ses clients. Examinées seules, les versions finales de Square et Rectangle paraissaient cohérentes et valides ; vues à travers les hypothèses raisonnables d'un programmeur, elles s'effondrent. On ne peut juger une conception dans le vide : il faut la juger à l'aune des hypothèses raisonnables de ses utilisateurs.

EST-UN concerne le comportement

Pourquoi le modèle apparemment raisonnable du carré et du rectangle a-t-il mal tourné ? Un carré n'est-il pas un rectangle ? La relation EST-UN ne tient-elle pas ? Pas du point de vue de g ! Un carré est peut-être un rectangle, mais un objet Square n'est définitivement pas un objet Rectangle, parce que son comportement n'est pas conforme aux attentes de g. Or c'est de comportement que le logiciel est fait. Le LSP rend clair qu'en conception orientée objet, la relation EST-UN porte sur le comportement raisonnablement attendu et dont les clients dépendent.

La conception par contrat

Beaucoup de développeurs sont mal à l'aise avec cette notion de comportement « raisonnablement attendu ». Une technique permet de rendre ces hypothèses explicites, et donc d'imposer le LSP : la conception par contrat (Design by Contract), formulée par Bertrand Meyer. L'auteur d'une classe y déclare le contrat de chaque méthode au moyen de préconditions et de postconditions. Les préconditions doivent être vraies pour que la méthode s'exécute ; à l'achèvement, la méthode garantit que les postconditions sont vraies. La postcondition de setWidth(w) sur un rectangle peut s'écrire ainsi : après l'appel, itsWidth === w et itsHeight est inchangé.

La règle de Meyer sur la redéclaration dans une dérivée est la suivante :

Une redéclaration de routine ne peut remplacer la précondition d'origine que par une précondition égale ou plus faible, et la postcondition d'origine que par une postcondition égale ou plus forte.

Autrement dit, les objets dérivés ne doivent pas exiger de leurs utilisateurs des préconditions plus fortes que celles de la base, et ils doivent respecter toutes les postconditions de la base. Or la postcondition de setWidth sur Square est plus faible que celle de Rectangle, puisqu'elle n'impose plus la contrainte « la hauteur reste inchangée ». La méthode setWidth de Square viole donc le contrat de la classe de base. On peut aussi spécifier ces contrats en écrivant des tests unitaires : tester soigneusement le comportement d'une classe rend ce comportement explicite, et les auteurs de code client consulteront ces tests pour savoir ce qu'ils peuvent raisonnablement supposer.

Un exemple réel : Set et PersistentSet

Quittons les carrés et les rectangles. Au début des années 1990, Martin acheta une bibliothèque tierce de conteneurs, apparentés aux sacs et aux ensembles de Smalltalk. Il en existait deux variétés : « bornée » (BoundedSet), fondée sur un tableau préalloué, rapide mais gourmande en mémoire ; et « non bornée » (UnboundedSet), fondée sur une liste chaînée, souple et économe mais plus lente. Insatisfait de ces interfaces tierces, Martin les enveloppa derrière sa propre abstraction Set, exposant des méthodes add, delete et isMember.

interface Set<T> {
  add(element: T): void;
  delete(element: T): void;
  isMember(element: T): boolean;
}

function printSet<T>(s: Set<T>, elements: Iterable<T>): void {
  for (const e of elements) {
    console.log(e);
  }
}

Ce regroupement présente un grand avantage : un client peut accepter un argument de type Set<T> sans se soucier de la variété concrète. Le programmeur choisit UnboundedSet quand la mémoire est rare et la vitesse secondaire, ou BoundedSet quand la mémoire abonde et la vitesse prime, sans qu'aucune fonction cliente n'en soit affectée.

Le problème

Martin voulut ajouter un PersistentSet : un ensemble qu'on peut écrire sur un flux et relire plus tard, éventuellement depuis une autre application. Malheureusement, le seul conteneur tiers persistant disponible n'était pas générique : il n'acceptait que des objets dérivant d'une classe PersistentObject. PersistentSet enveloppa donc ce conteneur tiers et lui délégua toutes ses méthodes. À la surface, cela paraît correct, mais l'implication est laide : tout élément ajouté à un PersistentSet doit dériver de PersistentObject, alors que l'interface Set n'impose aucune contrainte de ce genre.

// ❌ add lève une erreur si l'élément ne dérive pas de PersistentObject.
class PersistentSet<T> implements Set<T> {
  constructor(private readonly thirdParty: ThirdPartyPersistentSet) {}

  add(element: T): void {
    if (!(element instanceof PersistentObject)) {
      throw new TypeError("élément non persistable"); // surprise pour le client
    }
    this.thirdParty.add(element);
  }
  delete(element: T): void {
    /* ... */
  }
  isMember(element: T): boolean {
    return false; /* ... */
  }
}

Quand un client ajoute des membres à une Set, il ne peut pas savoir s'il s'agit en réalité d'un PersistentSet ; il n'a donc aucun moyen de savoir que ses éléments devraient dériver de PersistentObject. Tenter d'ajouter un objet non persistable provoque une erreur à l'exécution. Aucun des clients existants de Set n'attend d'exception sur add : cette extension viole le LSP. Le bogue est d'autant plus pénible à déboguer que l'erreur survient très loin du véritable défaut logique.

Une solution conforme au LSP : factoriser plutôt que dériver

La bonne solution consiste à reconnaître que PersistentSet n'EST-UN pas un Set : ce n'est pas une dérivée légitime. Seule la méthode add pose problème. On sépare donc les hiérarchies sans les disjoindre complètement, en factorisant les éléments communs (test d'appartenance, itération, suppression) dans une interface parente abstraite, dont Set et PersistentSet deviennent des frères.

interface MemberContainer<T> {
  delete(element: T): void;
  isMember(element: T): boolean;
}

interface Set<T> extends MemberContainer<T> {
  add(element: T): void;
}

interface PersistentSet<T extends PersistentObject> extends MemberContainer<T> {
  add(element: T): void; // contrainte exprimée dans le type
}

Ainsi, on peut itérer et tester l'appartenance d'un PersistentSet, mais on ne peut pas y ajouter d'objet non dérivé de PersistentObject par l'intermédiaire de l'interface Set.

Factoriser : le cas de la ligne et du segment

Autre cas intrigant : la Line (droite) et le LineSegment (segment). À première vue, ces deux classes semblent des candidates naturelles à l'héritage public : LineSegment a besoin de tous les membres de Line, ajoute une méthode getLength et redéfinit isOn. Pourtant, elles violent le LSP de façon subtile. Un utilisateur de Line est en droit d'attendre que tout point colinéaire à la droite soit « sur » elle — par exemple, le point d'ordonnée à l'origine retourné par intercept() satisfait isOn(intercept()) === true. Or, pour de nombreux segments, cette assertion échoue.

Faut-il s'en soucier ? C'est un arbitrage d'ingénieur. Il est de rares occasions où accepter un défaut subtil de comportement polymorphe est plus avantageux que de tordre la conception pour atteindre une conformité parfaite au LSP. Mais la garantie qu'une sous-classe fonctionnera toujours là où sa base est attendue est un puissant moyen de maîtriser la complexité, et on ne devrait pas y renoncer à la légère. Ici, si l'on a accès aux deux classes, on peut factoriser leurs éléments communs dans une classe de base abstraite LinearObject, dont isOn est abstraite.

abstract class LinearObject {
  constructor(
    protected readonly p1: Point,
    protected readonly p2: Point,
  ) {}

  getSlope(): number {
    /* ... */ return 0;
  }
  getIntercept(): number {
    /* ... */ return 0;
  }
  getP1(): Point {
    return this.p1;
  }
  getP2(): Point {
    return this.p2;
  }
  abstract isOn(p: Point): boolean; // abstraite
}

class Line extends LinearObject {
  isOn(p: Point): boolean {
    /* sur la droite infinie */ return true;
  }
}

class LineSegment extends LinearObject {
  getLength(): number {
    /* ... */ return 0;
  }
  isOn(p: Point): boolean {
    /* entre p1 et p2 seulement */ return true;
  }
}

LinearObject représente à la fois Line et LineSegment ; ses utilisateurs ne peuvent supposer connaître l'étendue de l'objet qu'ils manipulent, ils acceptent donc l'un ou l'autre sans problème, et les utilisateurs de Line ne croiseront jamais un LineSegment. La factorisation est un outil de conception qui s'applique de préférence avant qu'il n'y ait beaucoup de code écrit. Quand des qualités peuvent être factorisées hors de deux sous-classes, il y a fort à parier que d'autres classes auront plus tard besoin de ces mêmes qualités — un Ray (demi-droite), par exemple, devient alors substituable à LinearObject sans qu'aucun de ses utilisateurs n'ait à s'en soucier.

Heuristiques de détection

Quelques heuristiques simples signalent les violations du LSP. Toutes ont trait à des dérivées qui retirent de la fonctionnalité à leur base : une dérivée qui fait moins que sa base n'est généralement pas substituable à celle-ci.

  • Fonctions dégénérées dans les dérivées. Quand une méthode héritée est redéfinie par un corps vide parce que son auteur l'a jugée inutile dans la dérivée, les utilisateurs de la base ignorent qu'ils ne devraient pas l'appeler : c'est une violation potentielle.
  • Exceptions levées par les dérivées. Ajouter, dans les méthodes d'une dérivée, des exceptions que la base ne lève pas rend la dérivée non substituable, si les utilisateurs de la base ne s'y attendent pas.

En conclusion, le terme « EST-UN » est trop large pour définir un sous-type. La vraie définition d'un sous-type est « substituable », où la substituabilité est fixée par un contrat explicite ou implicite. C'est cette substituabilité qui permet à un module exprimé en termes d'un type de base d'être extensible sans modification — le mécanisme même de l'OCP.

DIP : inverser les dépendances

Le principe d'inversion des dépendances tient en deux énoncés :

  1. Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau. Les deux doivent dépendre d'abstractions.
  2. Les abstractions ne doivent pas dépendre des détails. Les détails doivent dépendre des abstractions.

Pourquoi le mot « inversion » ? Parce que les méthodes traditionnelles, comme l'analyse et la conception structurées, tendent à produire des structures où les modules de haut niveau dépendent des modules de bas niveau, où la politique dépend du détail. La structure de dépendance d'un programme orienté objet bien conçu est « inversée » par rapport à celle qu'engendrent les méthodes procédurales.

Considérons les implications d'un haut niveau dépendant du bas niveau. Ce sont les modules de haut niveau qui contiennent les décisions de politique importantes et les modèles métier : ils portent l'identité de l'application. Pourtant, quand ils dépendent des modules de bas niveau, le moindre changement de ces derniers peut les forcer à changer à leur tour. C'est absurde : ce sont les modules de haut niveau, porteurs des règles métier, qui devraient influencer les modules détaillés, et non l'inverse. Ce sont aussi les modules de haut niveau que l'on veut pouvoir réutiliser — et leur indépendance vis-à-vis du bas niveau est au cœur même de la conception des frameworks.

Le découpage en couches

Selon Booch, toute architecture orientée objet bien structurée comporte des couches clairement définies. Une interprétation naïve produit une structure où une couche Policy de haut niveau utilise une couche Mechanism, qui utilise à son tour une couche Utility détaillée. Cela paraît correct, mais la dépendance est transitive : Policy dépend de quelque chose qui dépend de Utility, donc Policy dépend transitivement de Utility. Un changement tout en bas remonte jusqu'en haut.

   Naïf                          Inversé
   ─────                         ───────
   Policy                        Policy ──▷ «interface» PolicyServiceInterface
     │                                            △
     ▼                            Mechanism ──────┘ ──▷ «interface» MechanismServiceInterface
   Mechanism                                                        △
     │                            Utility ────────────────────────┘

   Utility

Le modèle inversé est plus approprié : chaque couche supérieure déclare une interface abstraite pour les services dont elle a besoin, et les couches inférieures se réalisent à partir de ces interfaces. Les couches supérieures ne dépendent plus des inférieures ; ce sont les inférieures qui dépendent des interfaces déclarées dans les supérieures. Non seulement la dépendance transitive de Policy sur Utility est rompue, mais même la dépendance directe de Policy sur Mechanism l'est.

Note

L'inversion de propriété. L'inversion ici n'est pas seulement celle des dépendances, mais aussi celle de la propriété des interfaces. On pense d'ordinaire qu'une bibliothèque utilitaire possède sa propre interface. Mais sous le DIP, ce sont les clients qui possèdent les interfaces abstraites, et leurs serveurs qui en dérivent. C'est le principe d'Hollywood : « Ne nous appelez pas, nous vous appellerons. » Les modules de bas niveau fournissent l'implémentation d'interfaces déclarées dans les modules de haut niveau, et appelées par eux.

Dépendre des abstractions

Une lecture plus simple, mais très puissante, du DIP tient dans l'heuristique : « Dépendez des abstractions. » Aucune variable ne devrait détenir de référence vers une classe concrète ; aucune classe ne devrait dériver d'une classe concrète ; aucune méthode ne devrait redéfinir une méthode déjà implémentée d'une de ses bases. Cette heuristique est presque toujours violée au moins une fois par programme — quelqu'un doit bien instancier les classes concrètes — et elle n'a guère de sens pour les classes concrètes non volatiles. La classe string de la bibliothèque standard, par exemple, ne change presque jamais : en dépendre directement ne fait aucun mal. Ce sont les classes concrètes volatiles, celles que nous écrivons et qui changent souvent, qu'il faut isoler derrière une interface abstraite.

Un exemple simple : le bouton et la lampe

L'inversion des dépendances s'applique partout où une classe envoie un message à une autre. Considérons un objet Button et un objet Lamp. Le Button perçoit l'environnement : sur réception du message poll, il détermine si l'utilisateur l'a « pressé » — peu importe le mécanisme de détection (icône d'interface, bouton physique, détecteur de mouvement). La Lamp affecte l'environnement : sur turnOn, elle s'allume, sur turnOff, elle s'éteint — peu importe le mécanisme physique. Comment concevoir un système où le Button commande la Lamp ? Une conception naïve fait dépendre Button directement de Lamp.

// ❌ Button dépend directement de Lamp : impossible de réutiliser
// Button pour piloter un Motor, par exemple.
class Lamp {
  turnOn(): void {
    /* ... */
  }
  turnOff(): void {
    /* ... */
  }
}

class Button {
  constructor(private readonly itsLamp: Lamp) {}

  poll(): void {
    if (/* une condition */ true) {
      this.itsLamp.turnOn();
    }
  }
}

Cette solution viole le DIP : la politique de haut niveau n'est pas séparée de l'implémentation de bas niveau. Quelle est la politique de haut niveau ? C'est l'abstraction qui sous-tend l'application, la vérité qui ne varie pas quand on change les détails — ici : détecter un geste marche/arrêt de l'utilisateur et le relayer vers un objet cible. Quel mécanisme détecte le geste ? Quel est l'objet cible ? Détails sans importance pour l'abstraction. On améliore la conception en inversant la dépendance : Button détient désormais une association vers une interface abstraite que la Lamp implémente.

// ✅ Lamp dépend de l'abstraction ; Button ne connaît plus Lamp.
interface SwitchableDevice {
  turnOn(): void;
  turnOff(): void;
}

class Button {
  constructor(private readonly device: SwitchableDevice) {}

  poll(): void {
    if (/* une condition */ true) {
      this.device.turnOn();
    }
  }
}

class Lamp implements SwitchableDevice {
  turnOn(): void {
    /* ... */
  }
  turnOff(): void {
    /* ... */
  }
}

C'est désormais la Lamp qui dépend, et non plus elle dont on dépend. Le Button peut commander n'importe quel appareil prêt à implémenter SwitchableDevice, y compris des objets pas encore inventés. La Lamp dépend bien de SwitchableDevice, mais SwitchableDevice ne dépend pas de Button : la dépendance n'est que de nom. En nommant l'interface de façon générique (et non ButtonServer) et en la plaçant dans une bibliothèque séparée, on garantit que son usage n'entraîne pas celui de Button. Personne ne possède l'interface : elle est utilisable par beaucoup de clients et implémentable par beaucoup de serveurs ; elle doit donc se tenir seule, sans appartenir à aucun des deux groupes.

L'exemple du four et du thermostat

Considérons le logiciel qui régule un four. Il peut lire la température courante depuis un canal d'entrée/sortie et commander l'allumage ou l'extinction du four via un autre canal. Une première version mêle l'intention de haut niveau aux détails de bas niveau.

// ❌ L'algorithme de régulation est noyé dans des détails matériels.
const THERMOMETER = 0x86;
const FURNACE = 0x87;
const ENGAGE = 1;
const DISENGAGE = 0;

function regulate(minTemp: number, maxTemp: number): void {
  for (;;) {
    while (input(THERMOMETER) > minTemp) {
      wait(1);
    }
    output(FURNACE, ENGAGE);

    while (input(THERMOMETER) < maxTemp) {
      wait(1);
    }
    output(FURNACE, DISENGAGE);
  }
}

L'intention de haut niveau est claire, mais le code est encombré de détails de bas niveau et ne pourrait jamais être réutilisé avec un autre matériel de commande. Nous préférons inverser les dépendances : la fonction regulate prend deux arguments qui sont tous deux des interfaces. Un Thermometer qui se lit, un Heater qui s'engage et se désengage : c'est tout ce dont l'algorithme a besoin.

// ✅ La politique de régulation ne dépend plus d'aucun détail matériel.
interface Thermometer {
  read(): number;
}

interface Heater {
  engage(): void;
  disengage(): void;
}

function regulate(
  t: Thermometer,
  h: Heater,
  minTemp: number,
  maxTemp: number,
): void {
  for (;;) {
    while (t.read() > minTemp) {
      wait(1);
    }
    h.engage();

    while (t.read() < maxTemp) {
      wait(1);
    }
    h.disengage();
  }
}

Les dépendances sont inversées : la politique de régulation de haut niveau ne dépend plus d'aucun détail du thermomètre ou du four, et l'algorithme est joliment réutilisable. Les implémentations concrètes — un Thermometer sur canal d'entrée/sortie, un Heater sur canal d'entrée/sortie — réalisent les interfaces déclarées par le module de haut niveau.

Note

Le livre note qu'on peut aussi obtenir cette inversion par le polymorphisme statique des templates C++, sans le surcoût (ni la souplesse) du polymorphisme dynamique. Mais le polymorphisme statique a deux inconvénients : les types ne peuvent pas changer à l'exécution, et l'emploi d'un nouveau matériel force la recompilation. À moins d'exigences de vitesse extrêmes, le polymorphisme dynamique reste préférable.

En somme, la programmation procédurale crée une structure où la politique dépend du détail, ce qui rend les politiques vulnérables aux changements des détails. La programmation orientée objet inverse cette structure : détails et politiques dépendent tous de l'abstraction, et les interfaces de service sont souvent possédées par leurs clients. C'est cette inversion qui est la marque d'une bonne conception orientée objet. Peu importe le langage : si les dépendances sont inversées, la conception est orientée objet ; sinon, elle est procédurale.

ISP : ségréger les interfaces

L'ISP traite des inconvénients des interfaces « grasses » — celles qui ne sont pas cohésives, dont les méthodes peuvent être réparties en groupes servant chacun un ensemble de clients différent.

Les clients ne devraient pas être forcés de dépendre de méthodes qu'ils n'utilisent pas.

L'ISP reconnaît qu'il existe des objets aux interfaces non cohésives ; mais il suggère que les clients ne devraient pas les connaître comme une classe unique. Ils devraient plutôt connaître des classes de base abstraites aux interfaces cohésives.

La pollution d'interface

Considérons un système de sécurité doté d'objets Door qu'on peut verrouiller et déverrouiller, et qui savent s'ils sont ouverts ou fermés.

interface Door {
  lock(): void;
  unlock(): void;
  isDoorOpen(): boolean;
}

Cette interface est abstraite afin que les clients puissent utiliser des objets conformes à Door sans dépendre d'une implémentation particulière. Imaginons maintenant qu'une implémentation, TimedDoor, doive déclencher une alarme quand la porte reste ouverte trop longtemps. Pour cela, elle communique avec un Timer : un objet souhaitant être informé d'une temporisation appelle register, en fournissant le délai et un TimerClient dont la méthode timeout sera appelée à expiration.

interface TimerClient {
  timeout(): void;
}

class Timer {
  register(timeout: number, client: TimerClient): void {
    /* ... */
  }
}

Comment faire communiquer le TimerClient avec le TimedDoor ? Une solution naïve force Door, et donc TimedDoor, à hériter de TimerClient.

// ❌ Door est polluée par une méthode qu'elle ne requiert pas.
interface Door extends TimerClient {
  lock(): void;
  unlock(): void;
  isDoorOpen(): boolean;
  // timeout() héritée de TimerClient — sans rapport avec une porte
}

Cette solution, bien que courante, n'est pas sans problèmes. Door dépend maintenant de TimerClient. Or toutes les variétés de porte n'ont pas besoin de temporisation : l'abstraction Door d'origine n'avait rien à voir avec le minutage. Les dérivées sans minuterie devront fournir une implémentation dégénérée de timeout — violation potentielle du LSP — et les applications qui les utilisent devront importer la définition de TimerClient sans s'en servir. Cela sent la complexité superflue. C'est un exemple de pollution d'interface : l'interface de Door a été polluée par une méthode dont elle n'a pas besoin, au seul bénéfice d'une de ses sous-classes.

Des clients séparés veulent des interfaces séparées

Door et TimerClient sont des interfaces utilisées par des clients complètement différents : le Timer utilise TimerClient, et les manipulateurs de portes utilisent Door. Puisque les clients sont séparés, les interfaces devraient le rester aussi — parce que les clients exercent des forces sur les interfaces qu'ils utilisent.

Considérons que certains utilisateurs du Timer enregistrent plus d'une temporisation. Un TimedDoor détecte une ouverture et envoie register ; mais avant expiration, la porte se referme, attend, puis se rouvre, déclenchant un nouvel enregistrement. La première temporisation expire enfin et déclenche une fausse alarme. On corrige cela par convention : inclure un identifiant unique timeoutId dans chaque enregistrement, et le répéter dans l'appel timeout.

interface TimerClient {
  timeout(timeOutId: number): void;
}

class Timer {
  register(timeout: number, timeOutId: number, client: TimerClient): void {
    /* ... */
  }
}

Ce changement affecte légitimement tous les utilisateurs de TimerClient. Mais avec la conception naïve par héritage, il forcerait aussi Door et tous ses clients à changer ! Pourquoi un bogue dans TimerClient devrait-il affecter des clients de Door qui n'ont aucun besoin de minutage ? Quand un changement dans une partie du programme affecte des parties sans rapport, le coût et les répercussions deviennent imprévisibles, et le risque de dommages collatéraux croît dramatiquement. C'est exactement le couplage que l'ISP nous enjoint d'éviter.

Interfaces de classe contre interfaces d'objet

Le TimedDoor est un objet doté de deux interfaces séparées, utilisées par deux clients séparés — le Timer et les utilisateurs de Door. Ces deux interfaces doivent être implémentées dans le même objet, puisqu'elles manipulent les mêmes données. Comment se conformer à l'ISP en les gardant ensemble ? La réponse tient au fait que les clients d'un objet n'ont pas besoin d'y accéder par l'interface de l'objet : ils peuvent y accéder par délégation ou par une classe de base.

Séparation par délégation

Une solution consiste à créer un objet adaptateur qui dérive de TimerClient et délègue au TimedDoor. Quand le TimedDoor veut s'enregistrer auprès du Timer, il crée un DoorTimerAdapter et l'enregistre. Quand le Timer envoie timeout à l'adaptateur, celui-ci renvoie le message au TimedDoor.

class TimedDoor implements Door {
  lock(): void {
    /* ... */
  }
  unlock(): void {
    /* ... */
  }
  isDoorOpen(): boolean {
    return false; /* ... */
  }
  doorTimeOut(timeOutId: number): void {
    /* déclenche l'alarme */
  }
}

class DoorTimerAdapter implements TimerClient {
  constructor(private readonly timedDoor: TimedDoor) {}

  timeout(timeOutId: number): void {
    this.timedDoor.doorTimeOut(timeOutId);
  }
}

Cette solution se conforme à l'ISP et empêche le couplage des clients de Door au Timer : même si le changement de timeOutId est introduit, aucun utilisateur de Door n'est affecté. L'adaptateur peut en outre traduire l'interface TimerClient vers l'interface TimedDoor ; c'est donc une solution très générale. Elle est cependant un peu inélégante : elle crée un nouvel objet à chaque enregistrement, ce qui consomme un peu de temps et de mémoire — sensible dans les systèmes embarqués temps réel.

Séparation par héritage multiple

L'autre solution emploie l'héritage multiple : TimedDoor hérite à la fois de Door et de TimerClient. Les clients des deux interfaces peuvent utiliser TimedDoor, mais aucun ne dépend de la classe TimedDoor elle-même : ils utilisent le même objet par des interfaces séparées. En TypeScript, on l'exprime en implémentant les deux interfaces sur une seule classe.

class TimedDoor implements Door, TimerClient {
  lock(): void {
    /* ... */
  }
  unlock(): void {
    /* ... */
  }
  isDoorOpen(): boolean {
    return false; /* ... */
  }
  timeout(timeOutId: number): void {
    /* déclenche l'alarme */
  }
}

C'est la préférence habituelle de Martin. Il ne choisirait la délégation que si la traduction opérée par l'adaptateur était nécessaire, ou si des traductions différentes étaient requises à des moments différents.

L'exemple de l'interface du distributeur

Considérons maintenant un exemple plus significatif : l'interface utilisateur d'un distributeur automatique de billets (DAB). Elle doit être très flexible — la sortie peut devoir être traduite en plusieurs langues, présentée à l'écran, sur une tablette braille ou par synthèse vocale. On crée donc une classe de base abstraite ATMUI aux méthodes abstraites, réalisée par ScreenUI, BrailleUI, SpeechUI. Par ailleurs, chaque transaction du DAB est encapsulée comme une dérivée de Transaction : DepositTransaction, WithdrawalTransaction, TransferTransaction. Chacune invoque des méthodes de l'interface utilisateur — par exemple, DepositTransaction appelle requestDepositAmount.

// ❌ Interface « grasse » : chaque transaction dépend de méthodes
// qu'elle n'utilise pas.
interface UI {
  requestDepositAmount(): void;
  requestWithdrawalAmount(): void;
  requestTransferAmount(): void;
  informInsufficientFunds(): void;
}

C'est précisément la situation que l'ISP nous dit d'éviter : chaque transaction utilise des méthodes de l'interface qu'aucune autre n'emploie. Si l'on ajoutait une PayGasBillTransaction, il faudrait ajouter de nouvelles méthodes à UI ; comme DepositTransaction, WithdrawalTransaction et TransferTransaction dépendent toutes de UI, elles devraient toutes être recompilées — et, déployées en composants séparés, redéployées, alors qu'aucune de leur logique n'a changé. Cela sent la rigidité, la fragilité et la viscosité.

On évite ce couplage en ségrégeant l'interface UI en interfaces individuelles — DepositUI, WithdrawalUI, TransferUI — que l'interface finale UI réunit par héritage multiple.

// ✅ Chaque transaction ne dépend que de l'interface qu'elle utilise.
interface DepositUI {
  requestDepositAmount(): void;
}

interface WithdrawalUI {
  requestWithdrawalAmount(): void;
  informInsufficientFunds(): void;
}

interface TransferUI {
  requestTransferAmount(): void;
}

class DepositTransaction {
  constructor(private readonly ui: DepositUI) {}

  execute(): void {
    this.ui.requestDepositAmount();
  }
}

class WithdrawalTransaction {
  constructor(private readonly ui: WithdrawalUI) {}

  execute(): void {
    this.ui.requestWithdrawalAmount();
  }
}

// L'interface concrète réunit les interfaces ségrégées.
interface UI extends DepositUI, WithdrawalUI, TransferUI {}

Chaque transaction reçoit, à la construction, une référence vers sa version particulière de l'interface : DepositTransaction ne connaît que DepositUI, et ainsi de suite. Quand on crée une nouvelle dérivée de Transaction, il faut certes une nouvelle interface de base, et UI doit changer ; mais ces classes ne sont guère utilisées — sans doute seulement par le point d'entrée qui amorce le système et crée l'instance concrète de UI. L'impact de l'ajout est ainsi minimisé.

Note

La forme polyadique contre la forme monadique. Soit une fonction g qui a besoin d'accéder à la fois à DepositUI et à TransferUI. Faut-il écrire g(d: DepositUI, t: TransferUI) (polyadique) ou g(ui: UI) (monadique) ? La forme monadique est tentante, mais elle force g à dépendre de toutes les interfaces réunies dans UI : quand WithdrawalUI change, g et tous ses clients peuvent être affectés. La forme polyadique est donc souvent préférable, malgré sa lourdeur apparente — le fait que toutes les interfaces soient combinées dans un seul objet est une information dont g n'a pas besoin.

Comme pour tout principe, il faut se garder d'en faire trop : le spectre d'une classe aux centaines d'interfaces, les unes ségrégées par client, les autres par version, serait effrayant. Les clients peuvent souvent être regroupés selon les méthodes de service qu'ils appellent, et l'on crée alors une interface ségrégée par groupe plutôt que par client, ce qui réduit le nombre d'interfaces que le service doit implémenter. Quand les méthodes de groupes différents se chevauchent faiblement, on garde les interfaces séparées et l'on déclare les méthodes communes dans chacune ; la classe serveur les hérite de plusieurs interfaces mais ne les implémente qu'une fois. Enfin, lorsqu'une interface existante doit évoluer, on peut atténuer l'impact en ajoutant une nouvelle interface plutôt qu'en modifiant l'existante, les clients interrogeant l'objet pour savoir s'il offre la nouvelle.

À retenir

  • LSP : les sous-types doivent être substituables à leurs types de base. « EST-UN » est trop large ; la vraie définition d'un sous-type est « substituable », selon un contrat explicite ou implicite portant sur le comportement.
  • Une violation du LSP est une violation latente de l'OCP : quand une dérivée n'est pas substituable, les clients sont contraints d'ajouter des tests de type, ce qui ferme la porte à l'extension.
  • Les exemples canoniques — carré/rectangle (un invariant de Rectangle brisé), Set/PersistentSet (une exception inattendue sur add), droite/segment (isOn(intercept()) qui échoue) — montrent qu'une conception cohérente avec elle-même peut rester incohérente avec ses clients.
  • Outils : la conception par contrat (préconditions égales ou plus faibles, postconditions égales ou plus fortes) et la factorisation d'une base abstraite commune plutôt que la dérivation directe.
  • DIP : les modules de haut niveau et de bas niveau doivent tous deux dépendre d'abstractions ; les détails dépendent des abstractions, jamais l'inverse. C'est le principe d'Hollywood : « Ne nous appelez pas, nous vous appellerons. »
  • L'inversion porte aussi sur la propriété des interfaces : ce sont les clients de haut niveau qui possèdent les interfaces abstraites, et les serveurs de bas niveau qui en dérivent (bouton/lampe, thermostat générique).
  • ISP : un client ne doit jamais dépendre de méthodes qu'il n'utilise pas. On découpe les interfaces « grasses » en interfaces spécifiques au client, réunies au besoin par héritage multiple (porte temporisée, interface de distributeur), pour éviter les couplages inadvertants entre clients sans rapport.