Partager

31 janvier 2023

Les 5 principes SOLID expliqués aux développeurs

Les revues de code font partie des outils utilisés par les développeurs pour trouver les problèmes le plus tôt possible. Cependant, il est compliqué d'identifier ceux liés aux principes SOLID si on ne sait pas quoi regarder. Ainsi, plutôt que de vous proposer un énième article qui donne juste une définition des principes, je vous propose de vous montrer comment les reconnaître et les corriger si besoin, avec des exemples de code basés sur le design d'un jeu vidéo.

Pourquoi appliquer les principes SOLID

Les principes SOLID nous disent comment organiser nos fonctions et nos structures de données dans des classes et comment celles-ci doivent s'interconnecter.

Attention, bien que l'on utilise le mot classe, cela ne veut pas dire que ces principes sont applicables seulement au développement orienté objet. Les principes SOLID s'appliquent à tous les regroupements de fonctions et de structures, même si ces derniers ne sont pas vraiment dans une classe (au sens programmation orienté objet).

Le but de ces principes est la création de modules qui :

  • tolèrent le changement;
  • sont faciles à comprendre;
  • sont la base de composants qui peuvent être utilisés dans tout type de système.

La théorie des principes SOLID a été introduite dans leur forme actuelle, mais dans un ordre différent, en 2002 par Robert C. Martin (aussi connu sous le nom Uncle Bob). Cependant, ce n'est que quelques années plus tard que Michael Feathers a suggéré de les réorganiser pour créer l'acronyme SOLID.

Les concepts SOLID sont les suivants :

  • Single Responsability Principle (SRP) : principe de responsabilité unique
  • Open-Closed Principle (OCP) : principe ouvert-fermé
  • Liskov Substitution Principle (LSP) : principe de substitution de Liskov
  • Interface Segregation Principle (ISP) : principe de ségrégation des interfaces
  • Dependency Inversion Principle (DIP) : principe d'inversion des dépendances

Principe de responsabilité unique

Le principe de responsabilité unique énonce qu'une classe ne devrait avoir qu'une seule raison de changer. Séparer les responsabilités dans différentes classes est important puisque si une classe a plusieurs responsabilités et qu'une de ces dernières change, on pourrait impacter l'autre et pourrait causer des effets non voulus.

Note : tous les exemples ci-après sont inspirés de Leek Wars, un jeu de programmation dans lequel vous devez créer le plus puissant poireau et détruire vos ennemis.

Examinez le diagramme de classe suivant :

diagramme de classe de LeakAI

Dans cet exemple, la classe LeekAI a plusieurs responsabilités :

  • se déplacer sur la carte;
  • attaquer un poireau;
  • soigner un poireau;
  • jouer son tour en orchestrant les trois actions précédentes.

Une première raison de modifier la classe LeekAI serait de changer la façon dont on se déplace. Une autre serait de modifier comment le poireau se déplace sur la carte. Comme nous avons deux raisons de modifier cette classe, nous avons enfreint le principe de responsabilité unique et introduisons un couplage excessif.

Pour corriger, il faudrait extraire ces méthodes dans leur propre classe :

diagramme de classe respectant le principe de responsabilité unique des principes SOLID

Avec ce nouveau découpage, nous avons supprimé notre couplage excessif. La classe LeekAI n'a qu'une seule raison de changer : si l'on change l'ordre de nos phases de jeu pendant notre tour.

Même si le principe de responsabilité unique parle de classe, ce dernier peut aussi s'appliquer au niveau d'une fonction.

Prenons cette implémentation possible de MoveAI :

class MoveAI {
  move(leek: Leek, reason: Reason, cell: number) {
    if (reason === Reason.Attack) {
      // Calculer la meilleure position pour attaquer la case
      // Se déplacer vers la meilleur position pour attaquer
    } else if (reason === Reason.Retreat) {
      // S'éloigner de la case
    }
  }
}

La fonction move effectue plusieurs tâches, elle enfreint donc le principe de responsabilité unique. Elle effectue un mouvement différent en fonction de la raison et dans le cas d'une attaque, elle calcule aussi la meilleure position.

Corriger ce problème est trivial, il suffit de créer des fonctions dédiées.

class MoveAI {
  move(leek: Leek, reason: Reason, cell: number) {
    if(reason === 'ATTACK') {
      this.moveToAttack(leek, cell);
    } else if (reason === 'RETREAT') {
      this.moveAway(leek, cell);
    }
  }

  private moveToAttack(leek: Leek, cell: number) {
    const bestCellToAttack = this.getBestCellToAttack(leek, cell);
    this.moveToward(leek, bestCellToAttack);
  }

  private getBestCellToAttack(leek: Leek, cell: number) {
    // Calculer la meilleure position pour attaquer la case
  }

  private moveToward(leek: Leek, cell: number) {
    // Se déplacer vers la case
  }

  private moveAway(leek: Leek, cell: number) {
    // S'éloigner de la case
  }
}

Principe ouvert-fermé

Le principe ouvert-fermé formule qu'une entité logicielle doit être ouverte à l'extension mais fermée à la modification.

Une entité logicielle ouverte à l'extension signifie que le comportement d'un module peut changer, par exemple, en cas de changement de spécification. Cependant, elle doit être fermée à la modification pour empêcher un module de changer quand on change son comportement. Cela peut sembler contre-intuitif mais la clé pour appliquer ce principe est l'abstraction! En effet, si un module référence uniquement des abstractions il est possible d'ajouter une implémentation sans le modifier.

Par exemple, le code suivant ne respecte pas le principe ouvert-fermé :

class LeekAI {
  private readonly moveModule = new SimpleMoveAI();
  private readonly attackModule = new SimpleAttackAI();

  public play() {
    // ...
    moveModule.move(me, Reason.Attack, enemy);
  }
}

Dans le code ci-dessus, LeekAI dépend des classes concrètes SimpleMoveAI et SimpleAttackAI. Si l'on veut changer la manière dont notre IA se déplace mais que ces deux classes ne peuvent pas être modifiées (elles proviennent d'une librairie tierce par exemple), il faudrait modifier directement LeekAI, ce qui impliquerait que notre classe est ouverte à la modification, justement ce qu'il ne faut pas!

Pour remédier à ce problème, il faudrait créer des abstractions de SimpleMoveAI et SimpleAttackAI et utiliser ces abstractions dans LeekAI afin de pouvoir modifier la manière de se déplacer ou d'attaquer sans modifier notre module.

diagramme de classe respectant le principe ouvert-fermé des principes SOLID

Il est ensuite possible de modifier notre classe de cette façon :

class LeekAI {
  constructor(
    private readonly moveModule: MoveAI,
    private readonly attackModule: AttackAI
  ) {}

  public play() {
    // ...
    moveModule.move(me, Reason.Attack, enemy);
  }
}

La classe LeekAI ne connaît plus les implémentations concrètes pour se déplacer et attaquer. Si l'on souhaite modifier leur comportement, nous n'avons plus besoin de modifier LeekAI et sommes donc fermés à la modification. Si par exemple, on souhaite mettre en place une nouvelle façon d'attaquer, il faut créer une nouvelle implémentation de AttackAI : nous sommes ouverts à l'extension.

Nous verrons comment instancier les bonnes implémentations de MoveAI et AttackAI dans la section sur le principe d'inversion des dépendances, les deux principes étant forcément liés.

Principe de substitution de Liskov

Le principe de substitution de Liskov peut être paraphrasé comme suit : les sous-types doivent être substituables à leurs types de base. Il doit son nom à Barbara Liskov et décrit la manière dont les sous-types doivent pouvoir être utilisés à la place de leurs supertypes sans rompre la fonctionnalité du programme.

Nous parlons bien de sous-type et non de sous-classe. Un sous-type a une définition plus stricte et est utilisé pour indiquer que le type a été conçu pour remplacer son supertype. Une relation de sous-classe n'implique PAS une relation de sous-type.

Il y a quelques heuristiques qui peuvent vous donner des pistes sur la violation de principe de substitution de Liskov. Elles ont toutes trait à des classes dérivées qui, d'une manière ou d'une autre, retirent des fonctionnalités à leurs classes de base. Un dérivé qui fait moins que sa base n'est généralement pas substituable à cette base et viole donc le principe de substitution de Liskov.

Par exemple, si SmartLeek dérive de Leek :

class Leek {
  useWeapon(weapon: Weapon): boolean {
    // ...
  }
}

class SmartLeek extends Leek {
  useWeapon(weapon: Weapon): boolean {
    // ...
  }
}

En tant que classe dérivée, SmartLeek doit respecter les règles suivantes :

  • Ses préconditions (les assertions faites sur son état avant d'exécuter la fonction) de useWeapon de SmartLeek ne doivent pas être plus fortes que celles de Leek.
  • Ses postconditions (les assertions faites sur son état après avoir exécuté la fonction) ne doivent pas être plus faibles que celles de Leek.
  • Il ne doit pas lancer d'exception là où Leek n'en lance pas.
  • Son comportement ne doit pas être différent de celui de Leek.

Principe de ségrégation des interfaces

Ce principe stipule que plusieurs interfaces spécifiques sont préférables à une interface générale.

Regardez l'extrait de code suivant :

interface CanUseChipAndWeapon {
  setWeapon(weapon: number): void;
  useWeapon(entity: number): number;
  useChip(chip: number, entity: number): number;
}

class Leek implements CanUseChipAndWeapon {
  setWeapon(weapon: number) {
    // [...]
  }

  useWeapon(entity: number): number {
    // [...]
  }
  useChip(chip: number, entity: number): number {
    // [...]
  }
}

class Bulb implements CanUseChipAndWeapon {
  setWeapon(weapon: number) {
    // Do nothing
  }

  useWeapon(entity: number): number {
    // Do nothing
  }

  useChip(chip: number, entity: number): number {
    // [...]
  }
}

Dans l'exemple ci-dessus, nous avons deux classes Leek et Bulb qui implémentent CanUseChipAndWeapon. Cependant, les Bulb ne peuvent pas utiliser d'arme mais sont forcés d'implémenter les deux méthodes setWeapon et useWeapon.

Pour corriger ce problème, il faut séparer cette interface en deux plus petites.

interface CanUseChip {
  useChip(chip: number, entity: number): number;
}

interface CanUseWeapon {
  setWeapon(weapon: number): void;
  useWeapon(entity: number): number;
}

class Leek implements CanUseChip, CanUseWeapon {
  setWeapon(weapon: number) {
    // [...]
  }

  useWeapon(entity: number): number {
    // [...]
  }
  useChip(chip: number, entity: number): number {
    // [...]
  }
}

class Bulb implements CanUseChip {
  useChip(chip: number, entity: number): number {
    // [...]
  }
}

Ce découpage permet aux modules d'être indépendants les uns des autres et facilite la lecture et le refactoring.

Principe d'inversion des dépendances

Comme dit précédemment, le principe d'inversion des dépendances est fortement lié au principe ouvert-fermé. En effet, c'est ce principe qui rend possible le principe ouvert-fermé.

Le principe d'inversion des dépendances déclare :

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

Pour clarifier, reprenons le code de principe ouvert-fermé :

class LeekAI {
  private readonly moveModule = new SimpleMoveAI();
  private readonly attackModule = new SimpleAttackAI();

  public play() {
    // ...
    moveModule.move(me, Reason.Attack, enemy);
  }
}

Ici, LeekAI dépend de deux modules de bas niveau, donc nous ne respectons pas le principe d'inversion des dépendances. Pour corriger cela, nous allons utiliser le principe d'inversion de contrôle (IoC). Revoyons comment nous avons corrigé ce problème pour principe ouvert-fermé.

class LeekAI {
  constructor(
    private readonly moveModule: MoveAI,
    private readonly attackModule: AttackAI
  ) {}

  public play() {
    // ...
    moveModule.move(me, Reason.Attack, enemy);
  }
}

Cette fois-ci, notre module dépend d'abstractions et nos implémentations dépendent d'une interface comme on peut le voir sur le diagramme de classe.

diagramme de classe respectant le principe d'inversion des dépendances des principes SOLID

Enfin, pour faire le lien entre les implémentations concrètes et les abstractions, nous utilisons l'injection de dépendances (DI).

class LeekAI {
  constructor(
    private readonly moveModule: MoveAI,
    private readonly attackModule: AttackAI
  ) {}

  public play() {
    // ...
    moveModule.move(me, Reason.Attack, enemy);
  }
}

// Ailleurs dans votre code
const ai = new LeekAI(new SimpleMoveAI(), new SimpleAttackAI());
ai.play();

Conclusion

Dans cet article, nous avons étudié les principes SOLID, notamment savoir comment identifier les endroits où ils ne sont pas respectés et comment y remédier. En suivant ces principes directeurs pour vos modules, vous aurez une fondation solide pour les assembler et créer les composants qui formeront votre application.

Bibliographie

  • Robert C. Martin. Agile Software Development, Principles, Patterns, and Practices. Pearson, 2002.
  • Robert C. Martin. Clean Architecture: A Craftsman's Guide to Software Structure and Design. Prentice Hall, 2017.
Xavier Balloy

Xavier Balloy