La programmation orientée objet a introduit de nouvelles approches de la conception d'applications dans le développement de logiciels. En particulier, la POO a permis aux programmeurs de combiner des entités, unies par un objectif ou une fonctionnalité communs, dans des classes distinctes, conçues pour résoudre des problèmes indépendants et indépendants des autres parties de l'application. Cependant, l'utilisation de la POO seule ne signifie pas que le développeur est à l'abri de la possibilité de créer du code obscur et confus difficile à maintenir. Robert Martin, afin d'aider tous ceux qui souhaitent développer des applications de POO de haute qualité, a développé cinq principes de programmation et de conception orientées objet, dont ils parlent, avec l'aide de Michael Fazers, de l'acronyme SOLID.

Le matériel, dont nous publions la traduction aujourd'hui, est dédié aux bases de SOLID et est destiné aux débutants.
Qu'est-ce que SOLID?
Voici comment l'acronyme SOLID signifie:
- S: principe de responsabilité unique.
- O: Principe ouvert-fermé.
- L: Principe de substitution de Liskov (Barbara Liskov Substitution Principle).
- I: Principe de ségrégation des interfaces.
- D: Principe d'inversion de dépendance.
Nous allons maintenant considérer ces principes dans des exemples schématiques. Notez que l'objectif principal des exemples est d'aider le lecteur à comprendre les principes de SOLID, à apprendre à les appliquer et à les suivre lors de la conception d'applications. L'auteur du document n'a pas cherché à parvenir à un code de travail pouvant être utilisé dans des projets réels.
Principe de responsabilité exclusive
«Une course. Une seule chose. " - Loki raconte Skurge dans le film Thor: Ragnarok.
Chaque classe ne devrait résoudre qu'un seul problème.Une classe ne devrait être responsable que d'une chose. Si une classe est responsable de la résolution de plusieurs problèmes, ses sous-systèmes qui implémentent la solution de ces problèmes se révèlent liés les uns aux autres. Les changements dans l'un de ces sous-systèmes entraînent des changements dans un autre.
Notez que ce principe s'applique non seulement aux classes, mais également aux composants logiciels au sens large.
Par exemple, considérez ce code:
class Animal { constructor(name: string){ } getAnimalName() { } saveAnimal(a: Animal) { } }
La classe
Animal
présentée ici décrit une sorte d'animal. Cette classe viole le principe de la responsabilité exclusive. Comment exactement ce principe est-il violé?
Conformément au principe de la responsabilité unique, une classe ne doit résoudre qu'une seule tâche. Il résout les deux en travaillant avec l'entrepôt de données dans la méthode
saveAnimal
et en manipulant les propriétés de l'objet dans le constructeur et dans la méthode
getAnimalName
.
Comment une telle structure de classe peut-elle entraîner des problèmes?
Si la procédure de travail avec l'entrepôt de données utilisé par l'application change, vous devrez apporter des modifications à toutes les classes qui fonctionnent avec l'entrepôt. Cette architecture n'est pas flexible, les changements dans certains sous-systèmes affectent d'autres, ce qui ressemble à l'effet domino.
Afin de mettre le code ci-dessus en conformité avec le principe de la responsabilité exclusive, nous allons créer une autre classe dont la seule tâche est de travailler avec le référentiel, en particulier, en y stockant des objets de la classe
Animal
:
class Animal { constructor(name: string){ } getAnimalName() { } } class AnimalDB { getAnimal(a: Animal) { } saveAnimal(a: Animal) { } }
Voici ce que Steve Fenton dit à ce sujet: «Lors de la conception de classes, nous devons nous efforcer d'intégrer des composants connexes, c'est-à -dire ceux dans lesquels des changements se produisent pour les mêmes raisons. Nous devons essayer de séparer les composants, les changements qui provoquent diverses raisons. "
L'application correcte du principe de la responsabilité exclusive conduit à un haut degré de connectivité des éléments à l'intérieur du module, c'est-à -dire au fait que les tâches résolues en son sein correspondent bien à son objectif principal.
Principe ouvert-fermé
Les entités logicielles (classes, modules, fonctions) doivent être ouvertes pour l'expansion, mais pas pour la modification.Nous continuons à travailler sur la classe
Animal
.
class Animal { constructor(name: string){ } getAnimalName() { } }
Nous voulons trier la liste des animaux, chacun étant représenté par un objet de la classe
Animal
, et découvrir les sons qu'ils émettent. Imaginez que nous
AnimalSounds
ce problème en utilisant la fonction
AnimalSounds
:
//... const animals: Array<Animal> = [ new Animal('lion'), new Animal('mouse') ]; function AnimalSound(a: Array<Animal>) { for(int i = 0; i <= a.length; i++) { if(a[i].name == 'lion') return 'roar'; if(a[i].name == 'mouse') return 'squeak'; } } AnimalSound(animals);
Le principal problème de cette architecture est que la fonction détermine le type de son émis par un animal lors de l'analyse d'objets spécifiques. La fonction
AnimalSound
respecte pas le principe d'ouverture-fermeture, car, par exemple, lorsque de nouveaux types d'animaux apparaissent, nous devons la modifier afin de l'utiliser pour reconnaître les sons émis par eux.
Ajoutez un nouvel élément au tableau:
//... const animals: Array<Animal> = [ new Animal('lion'), new Animal('mouse'), new Animal('snake') ] //...
Après cela, nous devons changer le code de la fonction
AnimalSound
:
//... function AnimalSound(a: Array<Animal>) { for(int i = 0; i <= a.length; i++) { if(a[i].name == 'lion') return 'roar'; if(a[i].name == 'mouse') return 'squeak'; if(a[i].name == 'snake') return 'hiss'; } } AnimalSound(animals);
Comme vous pouvez le voir, lors de l'ajout d'un nouvel animal au tableau, vous devrez compléter le code de fonction. Un exemple est très simple, mais si une architecture similaire est utilisée dans un projet réel, la fonction devra être constamment développée, en y ajoutant de nouvelles expressions
if
.
Comment aligner la fonction
AnimalSound
sur le principe d'ouverture-fermeture? Par exemple, comme ceci:
class Animal { makeSound(); //... } class Lion extends Animal { makeSound() { return 'roar'; } } class Squirrel extends Animal { makeSound() { return 'squeak'; } } class Snake extends Animal { makeSound() { return 'hiss'; } } //... function AnimalSound(a: Array<Animal>) { for(int i = 0; i <= a.length; i++) { a[i].makeSound(); } } AnimalSound(animals);
Vous remarquerez peut-ĂŞtre que la classe
Animal
possède désormais une méthode
makeSound
virtuelle. Avec cette approche, il est nécessaire que les classes conçues pour décrire des animaux spécifiques étendent la classe
Animal
et mettent en œuvre cette méthode.
Par conséquent, chaque classe décrivant un animal aura sa propre méthode
makeSound
, et lors de l'itération sur un tableau avec des animaux dans la fonction
AnimalSound
,
AnimalSound
suffira d'appeler cette méthode pour chaque élément du tableau.
Si vous ajoutez maintenant un objet décrivant le nouvel animal au tableau, vous n'aurez pas à modifier la fonction
AnimalSound
. Nous l'avons aligné sur le principe d'ouverture-proximité.
Prenons un autre exemple.
Supposons que nous ayons un magasin. Nous offrons aux clients une remise de 20% en utilisant cette classe:
class Discount { giveDiscount() { return this.price * 0.2 } }
Maintenant, il a été décidé de diviser les clients en deux groupes. Les clients préférés (
fav
) bénéficient d'une remise de 20% et les clients VIP (
vip
) - le double de la remise, soit - 40%. Afin de mettre en œuvre cette logique, il a été décidé de modifier la classe comme suit:
class Discount { giveDiscount() { if(this.customer == 'fav') { return this.price * 0.2; } if(this.customer == 'vip') { return this.price * 0.4; } } }
Cette approche viole le principe d'ouverture-proximité. Comme vous pouvez le voir, ici, si nous devons accorder une remise spéciale à un certain groupe de clients, nous devons ajouter un nouveau code à la classe.
Afin de traiter ce code conformément au principe d'ouverture-proximité, nous ajoutons une nouvelle classe au projet qui étend la classe
Discount
. Dans cette nouvelle classe, nous implémentons un nouveau mécanisme:
class VIPDiscount: Discount { getDiscount() { return super.getDiscount() * 2; } }
Si vous décidez d'accorder une remise de 80% aux clients «super-VIP», cela devrait ressembler à ceci:
class SuperVIPDiscount: VIPDiscount { getDiscount() { return super.getDiscount() * 2; } }
Comme vous pouvez le voir, l'autonomisation des classes est utilisée ici, pas leur modification.
Le principe de substitution de Barbara Liskov
Il est nécessaire que les sous-classes remplacent leurs superclasses.Le but de ce principe est que les classes d'héritage peuvent être utilisées à la place des classes parentes à partir desquelles elles sont formées sans perturber le programme. S'il s'avère que le type de classe est vérifié dans le code, le principe de substitution est violé.
Considérez l'application de ce principe en revenant à l'exemple avec la classe
Animal
. Nous allons écrire une fonction conçue pour renvoyer des informations sur le nombre de membres d'un animal.
//... function AnimalLegCount(a: Array<Animal>) { for(int i = 0; i <= a.length; i++) { if(typeof a[i] == Lion) return LionLegCount(a[i]); if(typeof a[i] == Mouse) return MouseLegCount(a[i]); if(typeof a[i] == Snake) return SnakeLegCount(a[i]); } } AnimalLegCount(animals);
La fonction viole le principe de substitution (et le principe d'ouverture-fermeture). Ce code doit connaître les types de tous les objets traités par lui et, selon le type, utiliser la fonction correspondante pour calculer les membres d'un animal particulier. Par conséquent, lors de la création d'un nouveau type d'animal, la fonction devra être réécrite:
//... class Pigeon extends Animal { } const animals[]: Array<Animal> = [ //..., new Pigeon(); ] function AnimalLegCount(a: Array<Animal>) { for(int i = 0; i <= a.length; i++) { if(typeof a[i] == Lion) return LionLegCount(a[i]); if(typeof a[i] == Mouse) return MouseLegCount(a[i]); if(typeof a[i] == Snake) return SnakeLegCount(a[i]); if(typeof a[i] == Pigeon) return PigeonLegCount(a[i]); } } AnimalLegCount(animals);
Afin que cette fonction ne viole pas le principe de substitution, nous la transformons en utilisant les exigences formulées par Steve Fenton. Ils consistent dans le fait que les méthodes qui acceptent ou renvoient des valeurs avec le type d'une superclasse (
Animal
dans notre cas) doivent également accepter et renvoyer des valeurs dont les types sont ses sous-classes (
Pigeon
).
Armé de ces considérations, nous pouvons refaire la fonction
AnimalLegCount
:
function AnimalLegCount(a: Array<Animal>) { for(let i = 0; i <= a.length; i++) { a[i].LegCount(); } } AnimalLegCount(animals);
Maintenant, cette fonction ne s'intéresse pas aux types d'objets qui lui sont transmis. Elle appelle simplement leurs méthodes
LegCount
. Tout ce qu'elle sait sur les types, c'est que les objets qu'elle traite doivent appartenir Ă la classe
Animal
ou Ă ses sous-classes.
La méthode
LegCount
devrait maintenant apparaître dans la classe
Animal
:
class Animal {
Et ses sous-classes doivent implémenter cette méthode:
//... class Lion extends Animal{ //... LegCount() { //... } } //...
Par conséquent, par exemple, lors de l'accès à la méthode
LegCount
pour une instance de la classe
Lion
, la méthode implémentée dans cette classe est appelée et exactement ce qui peut être attendu de l'appel d'une telle méthode est retourné.
Désormais, la fonction
AnimalLegCount
pas besoin de savoir quel objet d'une sous-classe particulière de la classe
Animal
elle traite afin de trouver des informations sur le nombre de membres de l'animal représenté par cet objet. La fonction appelle simplement la méthode
LegCount
de la classe
Animal
, car les sous-classes de cette classe doivent implémenter cette méthode afin qu'elles puissent être utilisées à la place, sans violer le bon fonctionnement du programme.
Principe de séparation des interfaces
Créez des interfaces hautement spécialisées conçues pour un client spécifique. Les clients ne doivent pas dépendre d'interfaces qu'ils n'utilisent pas.Ce principe vise à combler les lacunes associées à la mise en œuvre de grandes interfaces.
Considérez l'interface
Shape
:
interface Shape { drawCircle(); drawSquare(); drawRectangle(); }
Il décrit les méthodes de dessin des cercles (
drawCircle
), des carrés (
drawSquare
) et des rectangles (
drawRectangle
). Par conséquent, les classes qui implémentent cette interface et représentent des formes géométriques individuelles, telles qu'un cercle, un carré et un rectangle, doivent contenir une implémentation de toutes ces méthodes. Cela ressemble à ceci:
class Circle implements Shape { drawCircle(){ //... } drawSquare(){ //... } drawRectangle(){ //... } } class Square implements Shape { drawCircle(){ //... } drawSquare(){ //... } drawRectangle(){ //... } } class Rectangle implements Shape { drawCircle(){ //... } drawSquare(){ //... } drawRectangle(){ //... } }
Un code bizarre s'est avéré. Par exemple, la classe
Rectangle
représentant un rectangle implémente des méthodes (
drawCircle
et
drawSquare
) dont il n'a pas du tout besoin. La mĂŞme chose peut ĂŞtre vue lors de l'analyse du code de deux autres classes.
Supposons que nous décidions d'ajouter une autre méthode à l'interface
Shape
,
drawTriangle
, conçue pour dessiner des triangles:
interface Shape { drawCircle(); drawSquare(); drawRectangle(); drawTriangle(); }
Il en résultera que les classes représentant des formes géométriques spécifiques
drawTriangle
implémenter la méthode
drawTriangle
. Sinon, une erreur se produira.
Comme vous pouvez le voir, avec cette approche, il est impossible de créer une classe qui implémente une méthode de sortie d'un cercle, mais n'implémente pas de méthodes pour dériver un carré, un rectangle et un triangle. De telles méthodes peuvent être implémentées de sorte que lorsqu'elles sont sorties, une erreur est générée indiquant qu'une telle opération ne peut pas être effectuée.
Le principe de séparation des interfaces nous met en garde contre la création d'interfaces comme
Shape
partir de notre exemple. Les clients (nous avons les classes
Circle
,
Square
et
Rectangle
) ne doivent pas implémenter de méthodes qu'ils n'ont pas besoin d'utiliser. En outre, ce principe indique que l'interface ne doit résoudre qu'une seule tâche (en cela, elle est similaire au principe de la responsabilité exclusive), donc tout ce qui dépasse le cadre de cette tâche doit être transféré vers une autre interface ou des interfaces.
Dans notre cas, l'interface
Shape
résout les problèmes pour lesquels il est nécessaire de créer des interfaces distinctes. Suite à cette idée, nous retravaillons le code en créant des interfaces distinctes pour résoudre diverses tâches hautement spécialisées:
interface Shape { draw(); } interface ICircle { drawCircle(); } interface ISquare { drawSquare(); } interface IRectangle { drawRectangle(); } interface ITriangle { drawTriangle(); } class Circle implements ICircle { drawCircle() {
Maintenant, l'interface
ICircle
utilisée uniquement pour dessiner des cercles, ainsi que d'autres interfaces spécialisées pour dessiner d'autres formes. L'interface
Shape
peut être utilisée comme interface universelle.
Principe d'inversion de dépendance
L'objet de la dépendance doit être une abstraction, pas quelque chose de spécifique.- Les modules de niveau supérieur ne doivent pas dépendre de modules de niveau inférieur. Les deux types de modules doivent dépendre d'abstractions.
- Les abstractions ne devraient pas dépendre des détails. Les détails doivent dépendre des abstractions.
Dans le processus de développement logiciel, il y a un moment où la fonctionnalité de l'application cesse de s'intégrer dans le même module. Lorsque cela se produit, nous devons résoudre le problème des dépendances de module. En conséquence, par exemple, il peut s'avérer que les composants de haut niveau dépendent des composants de bas niveau.
class XMLHttpService extends XMLHttpRequestService {} class Http { constructor(private xmlhttpService: XMLHttpService) { } get(url: string , options: any) { this.xmlhttpService.request(url,'GET'); } post() { this.xmlhttpService.request(url,'POST'); }
Ici, la classe
Http
est un composant de haut niveau et
XMLHttpService
est un composant de bas niveau. Une telle architecture viole la clause A du principe d'inversion de dépendance: «Les modules de niveaux supérieurs ne doivent pas dépendre de modules de niveaux inférieurs. Les deux types de modules doivent dépendre d'abstractions. »
La classe
Http
est forcée de dépendre de la classe
XMLHttpService
. Si nous décidons de changer le mécanisme utilisé par la classe
Http
pour interagir avec le réseau, disons que ce sera un service Node.js ou, par exemple, un service stub utilisé à des fins de test, nous devrons éditer toutes les instances de la classe
Http
en changeant le code correspondant. Cela viole le principe d'ouverture et de proximité.
La classe
Http
ne doit pas savoir exactement ce qui est utilisé pour établir une connexion réseau. Par conséquent, nous allons créer l'interface de
Connection
:
interface Connection { request(url: string, opts:any); }
L'interface de
Connection
contient une description de la méthode de
request
et nous transmettons l'argument Type de
Connection
Ă la classe
Http
:
class Http { constructor(private httpConnection: Connection) { } get(url: string , options: any) { this.httpConnection.request(url,'GET'); } post() { this.httpConnection.request(url,'POST'); }
Maintenant, indépendamment de ce qui est utilisé pour organiser l'interaction avec le réseau, la classe
Http
peut utiliser ce qui lui a été transmis, sans se soucier de ce qui est caché derrière l'interface de
Connection
.
Nous
XMLHttpService
classe
XMLHttpService
afin qu'elle implémente cette interface:
class XMLHttpService implements Connection { const xhr = new XMLHttpRequest();
Par conséquent, nous pouvons créer de nombreuses classes qui implémentent l'interface de
Connection
et conviennent Ă une utilisation dans la classe
Http
pour organiser l'échange de données sur le réseau:
class NodeHttpService implements Connection { request(url: string, opts:any) {
Comme vous pouvez le voir, ici les modules de haut niveau et de bas niveau dépendent des abstractions. La classe
Http
(module de haut niveau) dépend de l'interface de
Connection
(abstraction). Les
XMLHttpService
,
NodeHttpService
et
MockHttpService
(modules de bas niveau) dépendent également de l'interface de
Connection
.
De plus, il convient de noter qu'en suivant le principe de l'inversion de dépendance, nous observons le principe de substitution Barbara Liskov. À savoir, il s'avère que les types
XMLHttpService
,
NodeHttpService
et
MockHttpService
peuvent servir de remplacement pour le type de base
Connection
.
Résumé
Ici, nous avons examiné cinq principes SOLIDES auxquels chaque développeur OOP devrait adhérer. Cela peut ne pas être facile au début, mais si vous vous efforcez de le faire, renforçant les désirs de pratique, ces principes deviennent une partie naturelle du flux de travail, ce qui a un impact positif énorme sur la qualité des applications et facilite grandement leur soutien.
Chers lecteurs! Utilisez-vous des principes SOLIDES dans vos projets?
