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 :
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 :
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.
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
deSmartLeek
ne doivent pas être plus fortes que celles deLeek
. - 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.
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.