SOLID, et Solid, tout DEV doit savoir !

Bon, encore un mot-concept multiple…

Pascal Kotté
DEV Design CH-FR
12 min readOct 14, 2018

--

Dans le monde des DEV (Développeurs), on aime bien utiliser des termes compris par eux seulement (un peu comme les juristes entre eux, les plombiers entre eux, les chirurgiens etc…).

NB: Je suis (désormais) un intégrateur, je subis donc, pardon, je tente de faire fonctionner ce que les développeurs fabriquent, dans leurs forges numériques…

SOLID est un mot intéressant dans le contexte digital !

Il entre récemment en collision, avec une annonce de Tim Berners-Lee avec Inrupt… :

Mais il est aussi un concept important de bonnes pratiques dans la programmation orientée ‘Objets’, et important pour les services web distribués, ou SOAD (avec les microservices): S.O.L.I.D.

  1. Single responsibility: Une classe, une fonction, une seule responsabilité.
  2. Ouvert/Fermé: Ouverte pour l’extension, mais fermée à la modification.
  3. Liskov (Barbara, avec Jeannette Wing, pour une fois que nous avons des femmes en informatique !): Tu dois pouvoir dériver une nouvelle classe d’objet d’un premier, sans violer les propriétés du premier ! Le principe de substitution, sans exceptions !
  4. Interface segregation: Etablir des interfaces séparées pour les différents clients au lieu d’une interface générique. C’est un principe fondamental en ergonomie, je recommande même de séparer des interfaces pour des mêmes clients, de cultures différentes (pas simplement sur les langues, mais sur leurs aptitudes numériques). Mais en l’occurrence ici, c’est dans le contexte DEV, et on parle d’interfaces d’utilisation des objets.
  5. Dependency Inversion Principle (DIP): en séparant la dépendance des données d’avec les objets de classes supérieures, on s’assure que les modifications des classes supérieures, n’auront pas d'impacts sur les objects dérivés.

Voici un article sur l’autre SOLID, traduit de l’anglais issu de: Chidume Nnamdi, ce 9 octobre ! (Chidume Nnamdi)

— Traduction assistée —

Des principes solides que chaque développeur devrait connaître

Le type de programmation orienté objet a apporté une nouvelle conception au développement logiciel.

Cela permet aux développeurs de combiner des données ayant le même objectif / la même fonctionnalité dans une classe pour traiter l’unique objectif, quelle que soit l’application.

Mais, cette programmation orientée objet n’empêche pas les programmes déroutants ou impossibles à maintenir.

À ce titre, cinq lignes directrices ont été élaborées par Robert C. Martin. Ces cinq directives / principes facilitaient la tâche des développeurs pour la création de programmes lisibles et maintenables.

Ces cinq principes ont été appelés les principes SOLID (l’acronyme a été dérivé par Michael Feathers).

  • S: Principe de responsabilité unique
  • O: principe ouvert-fermé
  • L: principe de substitution de Liskov
  • I: Principe de séparation des interfaces
  • D: Principe d’inversion de dépendance

Nous en discuterons en détail ci-après (la pub).

Conseil : Les principes SOLID sont conçus pour créer des logiciels à partir de composants modulaires, encapsulés, extensibles et composables. Bit est un outil puissant dans la mise en pratique de ce principe: il vous aide à isoler, partager et gérer facilement de tels composants dans différents projets à grande échelle en équipe. Essaie!

Bit — Partage et construction avec des composants de code
Bit vous aide à partager, découvrir et utiliser des composants de code entre projets et applications pour créer de nouvelles fonctionnalités et…bitsrc.io

Vous pouvez en apprendre plus sur les principes SOLID et Bit ici .

Principe de responsabilité unique: S

“… Tu n’avais qu’un travail” — Loki à Skurge à Thor: Ragnarok

Une classe ne devrait avoir qu’un seul travail.

Une classe ne devrait être responsable que d’une chose. Si une classe a plus d’une responsabilité, elle devient couplée. Le changement d’une responsabilité entraîne la modification de l’autre responsabilité.

  • Remarque : ce principe s’applique non seulement aux classes, mais également aux composants logiciels et aux microservices.

Par exemple, considérons cette conception:

class Animal {
constructor(name: string){ }
getAnimalName() { }
saveAnimal(a: Animal) { }
}

La classe Animal enfreint le ‘S’’.

Comment cela viole-t-il le S?

Le S indique que les classes devraient avoir une seule responsabilité. Ici, nous pouvons dégager deux responsabilités:

  • la gestion de la base de données des animaux
  • et la gestion des propriétés des animaux.

Le constructeur et getAnimalName gèrent les propriétés de l’animal tandis que saveAnimal gère le stockage d’animaux sur une base de données.

Comment cette conception posera-t-elle des problèmes à l’avenir?

Si l’application change d’une manière qui affecte les fonctions de gestion de la base de données. Les classes qui utilisent les propriétés Animal devront être touchées et recompilées pour compenser les nouveaux changements.

Vous voyez que ce système sent la rigidité, c’est comme un effet domino, touchez une carte et affecte toutes les autres cartes en ligne.

Pour rendre cela conforme à S, nous créons une autre classe qui assumera la responsabilité exclusive de stocker un animal dans une base de données:

class Animal {
constructor(name: string){ }
getAnimalName() { }
}
class AnimalDB {
getAnimal(a: Animal) { }
saveAnimal(a: Animal) { }
}

Lors de la conception de nos classes, nous devrions viser à rassembler les fonctionnalités associées. Ainsi, chaque fois qu’elles ont tendance à changer, elles changent pour la même raison. Et nous devrions essayer de séparer les fonctionnalités si elles sont modifiées pour des raisons différentes. (Steve Fenton)

Avec l’application appropriée de ceux-ci, notre application devient hautement cohérente.

Principe ouvert-fermé: ‘O’

Les entités logicielles (classes, modules, fonctions) doivent pouvoir être étendues et non modifiées.

Continuons avec notre cours sur les animaux.

class Animal {
constructor(name: string){ }
getAnimalName() { }
}

Nous voulons parcourir une liste d’animaux et émettre leurs sons.

//...
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);

La fonction AnimalSound n’est pas conforme au principe d’ouverture-fermeture car elle ne peut pas être fermée contre de nouveaux types d’animaux.

Si nous ajoutons un nouvel animal, Snake:

//...
const animals: Array<Animal> = [
new Animal('lion'),
new Animal('mouse'),
new Animal('snake')
]
//...

Nous devons modifier 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);

Vous voyez, pour chaque nouvel animal, une nouvelle logique est ajoutée à la fonction AnimalSound. Ceci est un exemple assez simple. Lorsque votre application devient complexe et de plus en plus complexe, vous verrez que l’ ifinstruction sera répétée dans la AnimalSoundfonction chaque fois qu'un nouvel animal est ajouté, dans toute l'application.

Comment pouvons-nous le rendre (AnimalSound) conforme à ‘O’?

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);

Animal a maintenant une méthode virtuelle makeSound. Nous avons chaque animal étendre la classe Animal et implémenter la méthode virtuelle makeSound.

Chaque animal ajoute sa propre implémentation sur la manière dont il crée un son dans le makeSound. AnimalSound effectue une itération parmi la gamme d’animaux et appelle simplement sa méthode makeSound.

Maintenant, si nous ajoutons un nouvel animal, AnimalSound n’a pas besoin de changer. Tout ce que nous avons à faire, c’est d’ajouter le nouvel animal à l’éventail d’animaux.

AnimalSound est maintenant conforme au principe ‘O’.

Un autre exemple:

Imaginons que vous ayez un magasin et que vous accordiez un rabais de 20% à vos clients préférés utilisant cette classe:

class Discount { 
giveDiscount () {
return this.price * 0.2
}
}

Lorsque vous décidez d’offrir le double de la réduction de 20% aux clients VIP. Vous pouvez modifier la classe comme ceci:

class Discount { 
giveDiscount () {
if (this.customer == 'fav') {
return this.price * 0.2;
}
if (this.customer == 'vip') {
return this.price * 0.4;
}
}
}

Mais, cela ne respecte pas le principe ‘O’. Si nous voulons donner un nouveau pourcentage de réduction, peut-être à un diff. type de clients, vous verrez qu’une nouvelle logique sera ajoutée.

Pour le faire respecter le principe ‘O’, nous allons ajouter une nouvelle classe qui étendra la remise. Dans cette nouvelle classe, nous implémenterions son nouveau comportement:

class VIPDiscount: Discount { 
getDiscount () {
return super.getDiscount () * 2;
}
}

Si vous décidez de bénéficier d’une réduction de 80% sur les clients super VIP, voici le résultat

class SuperVIPDiscount: VIPDiscount { 
getDiscount () {
return super.getDiscount () * 2;
}
}

Vous voyez, extension sans modification.

Principe de substitution de Liskov: ‘L’

Une sous-classe doit être substituable à sa super-classe

L’objectif de ce principe est de vérifier qu’une sous-classe peut prendre la place de sa super-classe sans erreur. Si le code se trouve alors en train de vérifier le type de classe, il doit avoir violé ce principe.

Utilisons notre exemple 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);

Cela viole le principe du ‘L’ (et aussi le principe ‘O’). Il doit connaître tous les types d’animaux et appeler la leg-countingfonction associée .

À chaque nouvelle création d’un animal, la fonction doit être modifiée pour accepter le nouvel animal.

//...
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);

Pour que cette fonction suive le principe de L, nous suivrons les exigences de postulées par Steve Fenton:

  • Si la super-classe (Animal) a une méthode qui accepte un paramètre de type super-classe (Animal). Sa sous-classe (Pigeon) devrait accepter comme argument un type de super-classe (type Animal) ou un type de sous-classe (type Pigeon).
  • Si la super-classe retourne un type de super-classe (Animal). Sa sous-classe doit renvoyer un type de super-classe (type Animal) ou un type de sous-classe (Pigeon).

Maintenant, nous pouvons ré-implémenter la fonction AnimalLegCount:

function AnimalLegCount(a: Array<Animal>) {
for(let i = 0; i <= a.length; i++) {
a[i].LegCount();
}
}
AnimalLegCount(animals);

La fonction AnimalLegCount se soucie moins du type d’Animal passé, elle appelle simplement la méthode LegCount. Tout ce qu’il sait, c’est que le paramètre doit être de type Animal, soit la classe Animal, soit sa sous-classe.

La classe Animal doit maintenant implémenter / définir une méthode LegCount:

classe Animal { 
// ...
LegCount ();
}

Et ses sous-classes doivent implémenter la méthode LegCount:

//...
class Lion extends Animal{
//...
LegCount() {
//...
}
}
//...

Lorsqu’il est transmis à la fonction AnimalLegCount, il renvoie le nombre de jambes d’un lion.

Vous voyez, AnimalLegCount n’a pas besoin de connaître le type d’Animal pour renvoyer le nombre de ses jambes, il appelle simplement la méthode LegCount du type Animal car, par contrat, une sous-classe de la classe Animal doit implémenter la fonction LegCount.

Principe de séparation des interfaces: ‘I’ (i)

Créer des interfaces spécifiques à chaque type de client.

Les clients ne doivent pas être obligés de dépendre d’interfaces qu’ils n’utilisent pas.

Ce principe traite des inconvénients de la mise en œuvre de grandes interfaces.

Regardons l’interface de forme ci-dessous:

interface Shape { 
drawCircle ();
drawSquare ();
drawRectangle ();
}

Cette interface dessine des carrés, des cercles, des rectangles. Les classes Circle, Square ou Rectangle implémentant l’interface Shape doivent définir les méthodes drawCircle (), drawSquare (), drawRectangle ().

class Circle implements Shape {
drawCircle(){
//...
}
drawSquare(){
//...
}
drawRectangle(){
//...
}
}
class Square implements Shape {
drawCircle(){
//...
}
drawSquare(){
//...
}
drawRectangle(){
//...
}
}
class Rectangle implements Shape {
drawCircle(){
//...
}
drawSquare(){
//...
}
drawRectangle(){
//...
}
}

C’est assez drôle en regardant le code ci-dessus. La classe Rectangle implémente des méthodes (drawCircle et drawSquare) non utilisées, pas plus que Square qui implémente drawCircle, drawRectangle et la classe Circle (drawSquare, drawSquare).

Si nous ajoutons une autre méthode à l’interface Shape, comme drawTriangle (),

interface Shape { 
drawCircle ();
drawSquare ();
drawRectangle ();
drawTriangle ();
}

les classes doivent implémenter la nouvelle méthode sinon l’erreur sera levée.

Nous voyons qu’il est impossible d’implémenter une forme pouvant dessiner un cercle mais pas un rectangle, un carré ou un triangle. Nous pouvons simplement implémenter les méthodes pour générer une erreur indiquant que l’opération ne peut pas être effectuée.

‘I’ désapprouve la conception de cette interface Shape. les clients (ici Rectangle, Cercle et Carré) ne doivent pas être obligés de dépendre de méthodes dont ils n’ont pas besoin ou ne sont pas utilisés. En outre, ‘I’ indique que les interfaces ne doivent effectuer qu’un seul travail (à l’instar du principe ‘S’), tout regroupement supplémentaire de comportement doit être extrait d’une autre interface.

Ici, notre interface Shape effectue des actions qui doivent être gérées indépendamment par d’autres interfaces.

Pour rendre notre interface Shape conforme au principe ‘I’, nous séparons les actions en différentes interfaces:

interface Shape {
draw();
}
interface ICircle {
drawCircle();
}
interface ISquare {
drawSquare();
}
interface IRectangle {
drawRectangle();
}
interface ITriangle {
drawTriangle();
}
class Circle implements ICircle {
drawCircle() {
//...
}
}
class Square implements ISquare {
drawSquare() {
//...
}
}
class Rectangle implements IRectangle {
drawRectangle() {
//...
}
}
class Triangle implements ITriangle {
drawTriangle() {
//...
}
}
class CustomShape implements Shape {
draw(){
//...
}
}

L’interface ICircle gère uniquement le dessin de cercles, Shape gère le dessin de n’importe quelle forme :), ISquare gère uniquement le dessin de carrés et IRectangle gère le dessin de rectangles.

Principe d’inversion de dépendance: ‘D’

La dépendance devrait être sur des abstractions et non les données.

A. Les modules de haut niveau ne doivent pas dépendre de modules de bas niveau. Les deux devraient dépendre d’abstractions.

B. Les abstractions ne doivent pas dépendre des détails. Les détails devraient dépendre des abstractions.

Il arrive un moment du développement logiciel où notre application sera en grande partie composée de modules. Lorsque cela se produit, nous devons clarifier les choses en utilisant l’ injection de dépendance . Composants de haut niveau dépendant de composants de bas niveau pour fonctionner.

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, Http est le composant de haut niveau, alors que HttpService est le composant de bas niveau. Cette conception enfreint le ‘D

A: Les modules de haut niveau ne doivent pas dépendre de modules de bas niveau. Cela devrait dépendre de son abstraction.

Cette classe Http est obligée de dépendre de la classe XMLHttpService. Si nous devions changer le service de connexion Http, nous voudrions peut-être connecter l’objet Http à Internet via Nodejs ou même Mock. Nous devrons péniblement passer par toutes les instances de Http pour éditer le code, ce qui enfreint le principe ‘D’.

La classe Http devrait se soucier moins du type de service Http que vous utilisez. Nous faisons une interface de connexion:

interface Connection { 
request (url: string, opts: any);
}

L’interface Connection a une méthode de requête. Avec cela, nous passons un argument de type Connectionà notre Httpclasse:

class Http {
constructor(private httpConnection: Connection) { }
get(url: string , options: any) {
this.httpConnection.request(url,'GET');
}
post() {
this.httpConnection.request(url,'POST');
}
//...
}

Alors maintenant, quel que soit le type de service de connexion Http transmis à Http, celui-ci peut facilement se connecter à un réseau sans se soucier de connaître le type de connexion réseau.

Nous pouvons maintenant reprendre notre classe XMLHttpService pour implémenter l’interface de connexion:

class XMLHttpService implements Connection {
const xhr = new XMLHttpRequest();
//...
request(url: string, opts:any) {
xhr.open();
xhr.send();
}
}

Nous pouvons créer de nombreux type de Connection Http et le transmettre à notre classe Http sans soucis d'erreurs.

class NodeHttpService implements Connection {
request(url: string, opts:any) {
//...
}
}
class MockHttpService implements Connection {
request(url: string, opts:any) {
//...
}
}

Maintenant, nous pouvons voir que les modules de haut niveau et les modules de bas niveau dépendent d’abstractions. HttpLa classe (module de haut niveau) dépend à son tour de l'interface Connection (abstraction) et les types de service Http (modules de bas niveau) dépendent de l'interface Connection (abstraction).

En outre, ‘D’ nous force à ne pas violer le principe de substitution de Liskov: Les types Connection : Node- XML- MockHttpServicesont substituables pour leur type de parent Connection.

Conclusion

Nous avons couvert les cinq principes que chaque développeur de logiciel doit respecter ici. Au début, il peut être difficile de se conformer à tous ces principes, mais avec une pratique et une adhésion constante, il deviendra une partie intégrante de nous et aura un impact considérable sur la maintenance de nos applications.

Si vous avez des questions à ce sujet ou sur quelque chose que je devrais ajouter, corriger ou supprimer, n’hésitez pas à commenter l’article ci-dessous (Chidume Nnamdi) [Mais en Anglais je suppose, Pascal Kotté]

Pascal Kotté: J’ai partagé cet article, dans ce blog DEV-Design, car les principes SOLID ne sont pas intrinsèques aux supports et documentations des outils et languages de programmation, alors que c’est un concept fondamentalement important pour tout développeur, qui veut passer ‘PRO’ !

--

--

Pascal Kotté
DEV Design CH-FR