L'introduction du paradigme POO a popularisé des concepts de programmation clés tels que l'héritage, le polymorphisme, l'abstraction et l'encapsulation. La POO est rapidement devenue un paradigme de programmation largement accepté avec une implémentation dans plusieurs langages tels que Java, C, C#, JavaScript, etc. Le système POO est devenu plus complexe au fil du temps, mais son logiciel est resté résistant au changement. Pour améliorer l'extensibilité des logiciels et réduire la rigidité du code, Robert C. Martin (alias Oncle Bob) a introduit les principes SOLID au début des années 2000.
SOLID est un acronyme composé d'un ensemble de principes (principe de responsabilité unique, principe d'ouverture-fermeture, principe de substitution de Liskov, principe de ségrégation d'interface et principe d'inversion de dépendance) qui aide les ingénieurs logiciels à concevoir et à écrire des éléments maintenables, évolutifs et flexibles. code. Son objectif ? Améliorer la qualité des logiciels développés selon le paradigme de programmation orientée objet (POO).
Dans cet article, nous approfondirons tous les principes de SOLID et illustrerons comment ils sont mis en œuvre à l'aide de l'un des langages de programmation Web les plus populaires, JavaScript.
La première lettre de SOLID représente le principe de responsabilité unique. Ce principe suggère qu'une classe ou un module ne doit remplir qu'un seul rôle.
En termes simples, une classe devrait avoir une seule responsabilité ou une seule raison de changer. Si une classe gère plusieurs fonctionnalités, mettre à jour une fonctionnalité sans affecter les autres devient délicat. Les complications ultérieures pourraient entraîner un défaut de performance du logiciel. Pour éviter ce genre de problèmes, nous devons faire de notre mieux pour écrire un logiciel modulaire dans lequel les préoccupations sont séparées.
Si une classe a trop de responsabilités ou de fonctionnalités, cela devient un casse-tête à modifier. En utilisant le principe de responsabilité unique, nous pouvons écrire du code modulaire, plus facile à maintenir et moins sujet aux erreurs. Prenons, par exemple, un modèle de personne :
class Person { constructor(name, age, height, country){ this.name = name this.age = age this.height = height this.country = country } getPersonCountry(){ console.log(this.country) } greetPerson(){ console.log("Hi " + this.name) } static calculateAge(dob) { const today = new Date(); const birthDate = new Date(dob); let age = today.getFullYear() - birthDate.getFullYear(); const monthDiff = today.getMonth() - birthDate.getMonth(); if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { age--; } return age; } }
Le code ci-dessus semble correct, n'est-ce pas ? Pas tout à fait. L'exemple de code viole le principe de responsabilité unique. Au lieu d'être le seul modèle à partir duquel d'autres instances d'une personne peuvent être créées, la classe Person a également d'autres responsabilités telles que calculateAge, greetPerson et getPersonCountry.
Ces responsabilités supplémentaires gérées par la classe Person rendent difficile la modification d'un seul aspect du code. Par exemple, si vous avez tenté de refactoriser le calculateAge, vous pourriez également être obligé de refactoriser le modèle Person. En fonction de la compacité et de la complexité de notre base de code, il peut être difficile de reconfigurer le code sans provoquer d'erreurs.
Essayons de réviser l'erreur. Nous pouvons séparer les responsabilités en différentes classes, comme ceci :
class Person { constructor(name, age, height, country){ this.name = name this.age = age this.height = height this.country = country } getPersonCountry(){ console.log(this.country) } greetPerson(){ console.log("Hi " + this.name) } static calculateAge(dob) { const today = new Date(); const birthDate = new Date(dob); let age = today.getFullYear() - birthDate.getFullYear(); const monthDiff = today.getMonth() - birthDate.getMonth(); if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { age--; } return age; } }
Comme vous pouvez le voir dans l’exemple de code ci-dessus, nous avons séparé nos responsabilités. La classe Person est désormais un modèle avec lequel nous pouvons créer un nouvel objet personne. Et la classe PersonUtils n’a qu’une seule responsabilité : calculer l’âge d’une personne. La classe PersonService gère les salutations et nous montre le pays de chaque personne.
Si nous le voulons, nous pouvons encore réduire davantage ce processus. Suite au SRP, nous souhaitons découpler la responsabilité d'une classe au strict minimum afin qu'en cas de problème, la refactorisation et le débogage puissent être effectués sans trop de tracas.
En divisant les fonctionnalités en classes distinctes, nous adhérons au principe de responsabilité unique et veillons à ce que chaque classe soit responsable d'un aspect spécifique de l'application.
Avant de passer au principe suivant, il convient de noter qu'adhérer au SRP ne signifie pas que chaque classe doit strictement contenir une seule méthode ou fonctionnalité.
Cependant, adhérer au principe de responsabilité unique signifie que nous devons être intentionnels dans l'attribution de fonctionnalités aux classes. Tout ce qu’une classe réalise doit être étroitement lié dans tous les sens du terme. Nous devons faire attention à ne pas avoir plusieurs classes éparpillées partout, et nous devons par tous les moyens éviter les classes surchargées dans notre base de code.
Le principe ouvert-fermé stipule que les composants logiciels (classes, fonctions, modules, etc.) doivent être ouverts à l'extension et fermés à la modification. Je sais ce que vous pensez – oui, cette idée peut sembler contradictoire au premier abord. Mais l'OCP demande simplement que le logiciel soit conçu de manière à permettre une extension sans nécessairement modifier le code source.
L'OCP est crucial pour maintenir de grandes bases de code, car cette ligne directrice est ce qui vous permet d'introduire de nouvelles fonctionnalités avec peu ou pas de risque de casser le code. Au lieu de modifier les classes ou modules existants lorsque de nouveaux besoins surviennent, vous devez étendre les classes concernées en ajoutant de nouveaux composants. Ce faisant, assurez-vous de vérifier que le nouveau composant n’introduit aucun bug dans le système.
Le principe OC peut être réalisé en JavaScript à l'aide de la fonctionnalité d'héritage de classe ES6.
Les extraits de code suivants illustrent comment implémenter le principe Ouvert-Closé en JavaScript, en utilisant le mot-clé de classe ES6 susmentionné :
class Person { constructor(name, dateOfBirth, height, country){ this.name = name this.dateOfBirth = dateOfBirth this.height = height this.country = country } } class PersonUtils { static calculateAge(dob) { const today = new Date(); const birthDate = new Date(dob); let age = today.getFullYear() - birthDate.getFullYear(); const monthDiff = today.getMonth() - birthDate.getMonth(); if(monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { age--; } return age; } } const person = new Person("John", new Date(1994, 11, 23), "6ft", "USA"); console.log("Age: " + PersonUtils.calculateAge(person.dateOfBirth)); class PersonService { getPersonCountry(){ console.log(this.country) } greetPerson(){ console.log("Hi " + this.name) } }
Le code ci-dessus fonctionne bien, mais il se limite au calcul uniquement de l’aire d’un rectangle. Imaginez maintenant qu’il existe une nouvelle exigence de calcul. Disons, par exemple, que nous devons calculer l’aire d’un cercle. Nous devrons modifier la classe shapeProcessor pour répondre à cela. Cependant, conformément à la norme JavaScript ES6, nous pouvons étendre cette fonctionnalité pour prendre en compte les zones de nouvelles formes sans nécessairement modifier la classe shapeProcessor.
Nous pouvons faire ça comme ceci :
class Person { constructor(name, age, height, country){ this.name = name this.age = age this.height = height this.country = country } getPersonCountry(){ console.log(this.country) } greetPerson(){ console.log("Hi " + this.name) } static calculateAge(dob) { const today = new Date(); const birthDate = new Date(dob); let age = today.getFullYear() - birthDate.getFullYear(); const monthDiff = today.getMonth() - birthDate.getMonth(); if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { age--; } return age; } }
Dans l'extrait de code ci-dessus, nous avons étendu les fonctionnalités de la classe Shape en utilisant le mot-clé extends. Dans chaque sous-classe, nous remplaçons l’implémentation de la méthode Area(). En suivant ce principe, nous pouvons ajouter plus de formes et de zones de traitement sans avoir besoin de modifier les fonctionnalités de la classe ShapeProcessor.
Le principe de substitution de Liskov stipule qu'un objet d'une sous-classe doit pouvoir remplacer un objet d'une superclasse sans casser le code. Décomposons comment cela fonctionne avec un exemple : si L est une sous-classe de P, alors un objet de L devrait remplacer un objet de P sans casser le système. Cela signifie simplement qu'une sous-classe doit pouvoir remplacer une méthode de superclasse de manière à ne pas casser le système.
En pratique, le principe de substitution de Liskov garantit le respect des conditions suivantes :
Il est temps d'illustrer le principe de substitution de Liskov avec des exemples de code JavaScript. Jetez un oeil :
class Person { constructor(name, dateOfBirth, height, country){ this.name = name this.dateOfBirth = dateOfBirth this.height = height this.country = country } } class PersonUtils { static calculateAge(dob) { const today = new Date(); const birthDate = new Date(dob); let age = today.getFullYear() - birthDate.getFullYear(); const monthDiff = today.getMonth() - birthDate.getMonth(); if(monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { age--; } return age; } } const person = new Person("John", new Date(1994, 11, 23), "6ft", "USA"); console.log("Age: " + PersonUtils.calculateAge(person.dateOfBirth)); class PersonService { getPersonCountry(){ console.log(this.country) } greetPerson(){ console.log("Hi " + this.name) } }
Dans l'extrait de code ci-dessus, nous avons créé deux sous-classes (Bicycle et Car) et une superclasse (Vehicle). Pour les besoins de cet article, nous avons implémenté une méthode unique (OnEngine) pour la superclasse.
L'une des conditions fondamentales du LSP est que les sous-classes doivent remplacer les fonctionnalités des classes parentes sans casser le code. En gardant cela à l’esprit, voyons comment l’extrait de code que nous venons de voir viole le principe de substitution de Liskov. En réalité, une voiture a un moteur et peut allumer un moteur, mais techniquement, un vélo n’a pas de moteur et ne peut donc pas allumer de moteur. Ainsi, un vélo ne peut pas remplacer la méthode OnEngine dans la classe Vehicle sans casser le code.
Nous avons maintenant identifié la section du code qui viole le principe de substitution de Liskov. La classe Car peut remplacer la fonctionnalité OnEngine dans la superclasse et l'implémenter de manière à la différencier des autres véhicules (comme un avion, par exemple) et le code ne sera pas cassé. La classe Car satisfait au principe de substitution de Liskov.
Dans l'extrait de code ci-dessous, nous illustrerons comment structurer le code pour être conforme au principe de substitution de Liskov :
class Person { constructor(name, age, height, country){ this.name = name this.age = age this.height = height this.country = country } getPersonCountry(){ console.log(this.country) } greetPerson(){ console.log("Hi " + this.name) } static calculateAge(dob) { const today = new Date(); const birthDate = new Date(dob); let age = today.getFullYear() - birthDate.getFullYear(); const monthDiff = today.getMonth() - birthDate.getMonth(); if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { age--; } return age; } }
Voici un exemple basique d'une classe Vehicle avec une fonctionnalité générale, move. C'est une croyance générale que tous les véhicules se déplacent ; ils se déplacent simplement via différents mécanismes. Une façon dont nous allons illustrer LSP est de remplacer la méthode move() et de l'implémenter d'une manière qui décrit comment un véhicule particulier, par exemple une voiture, se déplacerait.
Pour ce faire, nous allons créer une classe Car qui étend la classe Vehicle et remplace la méthode move pour l'adapter au mouvement d'une voiture, comme ceci :
class Person { constructor(name, dateOfBirth, height, country){ this.name = name this.dateOfBirth = dateOfBirth this.height = height this.country = country } } class PersonUtils { static calculateAge(dob) { const today = new Date(); const birthDate = new Date(dob); let age = today.getFullYear() - birthDate.getFullYear(); const monthDiff = today.getMonth() - birthDate.getMonth(); if(monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { age--; } return age; } } const person = new Person("John", new Date(1994, 11, 23), "6ft", "USA"); console.log("Age: " + PersonUtils.calculateAge(person.dateOfBirth)); class PersonService { getPersonCountry(){ console.log(this.country) } greetPerson(){ console.log("Hi " + this.name) } }
Nous pouvons toujours implémenter la méthode move dans une autre classe de sous-véhicules, par exemple un avion.
Voici comment procéder :
class Rectangle { constructor(width, height) { this.width = width; this.height = height; } area() { return this.width * this.height; } } class ShapeProcessor { calculateArea(shape) { if (shape instanceof Rectangle) { return shape.area(); } } } const rectangle = new Rectangle(10, 20); const shapeProcessor = new ShapeProcessor(); console.log(shapeProcessor.calculateArea(rectangle));
Dans ces deux exemples ci-dessus, nous avons illustré des concepts clés tels que l'héritage et le remplacement de méthode.
N.B : Une fonctionnalité de programmation qui permet aux sous-classes d'implémenter une méthode déjà définie dans la classe parent est appelée méthode overriding.
Faisons un peu de ménage et mettons tout en place, comme ceci :
class Shape { area() { console.log("Override method area in subclass"); } } class Rectangle extends Shape { constructor(width, height) { super(); this.width = width; this.height = height; } area() { return this.width * this.height; } } class Circle extends Shape { constructor(radius) { super(); this.radius = radius; } area() { return Math.PI * this.radius * this.radius; } } class ShapeProcessor { calculateArea(shape) { return shape.area(); } } const rectangle = new Rectangle(20, 10); const circle = new Circle(2); const shapeProcessor = new ShapeProcessor(); console.log(shapeProcessor.calculateArea(rectangle)); console.log(shapeProcessor.calculateArea(circle));
Maintenant, nous avons 2 sous-classes héritant et remplaçant une seule fonctionnalité de la classe parent et l'implémentant selon leurs besoins. Cette nouvelle implémentation ne casse pas le code.
Le principe de ségrégation des interfaces stipule qu'aucun client ne doit être obligé de dépendre d'une interface qu'il n'utilise pas. Il souhaite que nous créions des interfaces plus petites et plus spécifiques, adaptées à des clients particuliers, plutôt que d’avoir une grande interface monolithique qui oblige les clients à implémenter des méthodes dont ils n’ont pas besoin.
Le fait de garder nos interfaces compactes facilite le débogage, la maintenance, les tests et l'extension des bases de code. Sans le FAI, une modification dans une partie d'une grande interface pourrait forcer des modifications dans des parties non liées de la base de code, nous obligeant à effectuer une refactorisation du code qui, dans la plupart des cas, en fonction de la taille de la base de code, peut être une tâche difficile.
JavaScript, contrairement aux langages de programmation basés sur C comme Java, ne prend pas en charge les interfaces. Cependant, il existe des techniques avec lesquelles les interfaces sont implémentées en JavaScript.
Les interfaces sont un ensemble de signatures de méthodes qu'une classe doit implémenter.
En JavaScript, vous définissez une interface comme un objet avec des noms de méthodes et des signatures de fonctions, comme ceci :
class Person { constructor(name, age, height, country){ this.name = name this.age = age this.height = height this.country = country } getPersonCountry(){ console.log(this.country) } greetPerson(){ console.log("Hi " + this.name) } static calculateAge(dob) { const today = new Date(); const birthDate = new Date(dob); let age = today.getFullYear() - birthDate.getFullYear(); const monthDiff = today.getMonth() - birthDate.getMonth(); if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { age--; } return age; } }
Pour implémenter une interface en JavaScript, créez une classe et assurez-vous qu'elle contient des méthodes avec les mêmes noms et signatures que ceux spécifiés dans l'interface :
class Person { constructor(name, dateOfBirth, height, country){ this.name = name this.dateOfBirth = dateOfBirth this.height = height this.country = country } } class PersonUtils { static calculateAge(dob) { const today = new Date(); const birthDate = new Date(dob); let age = today.getFullYear() - birthDate.getFullYear(); const monthDiff = today.getMonth() - birthDate.getMonth(); if(monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { age--; } return age; } } const person = new Person("John", new Date(1994, 11, 23), "6ft", "USA"); console.log("Age: " + PersonUtils.calculateAge(person.dateOfBirth)); class PersonService { getPersonCountry(){ console.log(this.country) } greetPerson(){ console.log("Hi " + this.name) } }
Nous avons maintenant compris comment créer et utiliser des interfaces en JavaScript. La prochaine chose que nous devons faire est d'illustrer comment séparer les interfaces en JavaScript afin que nous puissions voir comment tout s'articule et facilite la maintenance du code.
Dans l'exemple suivant, nous utiliserons une imprimante pour illustrer le principe de ségrégation des interfaces.
En supposant que nous ayons une imprimante, un scanner et un fax, créons une interface définissant les fonctions de ces objets :
class Rectangle { constructor(width, height) { this.width = width; this.height = height; } area() { return this.width * this.height; } } class ShapeProcessor { calculateArea(shape) { if (shape instanceof Rectangle) { return shape.area(); } } } const rectangle = new Rectangle(10, 20); const shapeProcessor = new ShapeProcessor(); console.log(shapeProcessor.calculateArea(rectangle));
Dans le code ci-dessus, nous avons créé une liste d'interfaces séparées ou séparées contre une grande interface qui définit toutes ces fonctionnalités. En divisant ces fonctionnalités en éléments plus petits et en interfaces plus spécifiques, nous permettons à différents clients d'implémenter uniquement les méthodes dont ils ont besoin et en éliminant tous les autres éléments.
Dans la prochaine étape, nous créerons des classes qui implémentent ces interfaces. Suivant le principe de ségrégation des interfaces, chaque classe implémentera uniquement les méthodes dont elle a besoin.
Si nous voulons implémenter une imprimante de base qui ne peut imprimer que des documents, nous pouvons simplement implémenter la méthode print() via l'interface imprimante, comme ceci :
class Shape { area() { console.log("Override method area in subclass"); } } class Rectangle extends Shape { constructor(width, height) { super(); this.width = width; this.height = height; } area() { return this.width * this.height; } } class Circle extends Shape { constructor(radius) { super(); this.radius = radius; } area() { return Math.PI * this.radius * this.radius; } } class ShapeProcessor { calculateArea(shape) { return shape.area(); } } const rectangle = new Rectangle(20, 10); const circle = new Circle(2); const shapeProcessor = new ShapeProcessor(); console.log(shapeProcessor.calculateArea(rectangle)); console.log(shapeProcessor.calculateArea(circle));
Cette classe implémente uniquement PrinterInterface. Il n'implémente pas de méthode de numérisation ou de télécopie. En suivant le principe de ségrégation des interfaces, le client — dans ce cas, la classe Printer — a réduit sa complexité et amélioré les performances d'un logiciel.
Maintenant notre dernier principe : le principe d’inversion de dépendance. Ce principe dit que les modules de niveau supérieur (logique métier) doivent s'appuyer sur l'abstraction plutôt que de s'appuyer directement sur des modules de niveau inférieur (concrétion). Cela nous aide à réduire les dépendances au code et offre aux développeurs la flexibilité de modifier et d'étendre les applications à des niveaux supérieurs sans rencontrer de complications.
Pourquoi le principe d'inversion de dépendance favorise-t-il l'abstraction par rapport aux dépendances directes ? En effet, l’introduction d’abstractions réduit les impacts potentiels des changements, améliore la testabilité (se moquant des abstractions au lieu d’implémentations concrètes) et permet d’obtenir un degré plus élevé de flexibilité dans votre code. Cette règle facilite l'extension des composants logiciels grâce à une approche modulaire et nous aide également à modifier les composants de bas niveau sans affecter la logique de haut niveau.
Adhérer au DIP facilite la maintenance, l'extension et la mise à l'échelle du code, éliminant ainsi les bogues qui pourraient survenir en raison de modifications apportées au code. Il recommande aux développeurs d'utiliser un couplage lâche au lieu d'un couplage étroit entre les classes. Généralement, en adoptant un état d'esprit qui donne la priorité aux abstractions plutôt qu'aux dépendances directes, les équipes gagneront en agilité pour s'adapter et ajouter de nouvelles fonctionnalités ou modifier d'anciens composants sans provoquer de perturbations. En JavaScript, nous sommes capables d'implémenter DIP en utilisant l'approche d'injection de dépendances, comme ceci :
class Person { constructor(name, age, height, country){ this.name = name this.age = age this.height = height this.country = country } getPersonCountry(){ console.log(this.country) } greetPerson(){ console.log("Hi " + this.name) } static calculateAge(dob) { const today = new Date(); const birthDate = new Date(dob); let age = today.getFullYear() - birthDate.getFullYear(); const monthDiff = today.getMonth() - birthDate.getMonth(); if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { age--; } return age; } }
Dans l'exemple de base ci-dessus, la classe Application est le module de haut niveau qui dépend de l'abstraction de la base de données. Nous avons créé deux classes de base de données : MySQLDatabase et MongoDBDatabase. Les bases de données sont des modules de bas niveau et leurs instances sont injectées dans le runtime de l'application sans modifier l'application elle-même.
Le principe SOLID est un élément fondamental pour la conception de logiciels évolutifs, maintenables et robustes. Cet ensemble de principes aide les développeurs à écrire du code propre, modulaire et adaptable.
Le principe SOLID favorise la fonctionnalité cohérente, l'extensibilité sans modification, la substitution d'objets, la séparation d'interface et l'abstraction sur des dépendances concrètes. Assurez-vous d'intégrer les principes SOLID dans votre code pour éviter les bugs et profiter de tous leurs avantages.
Le débogage du code est toujours une tâche fastidieuse. Mais plus vous comprenez vos erreurs, plus il est facile de les corriger.
LogRocket vous permet de comprendre ces erreurs de manière nouvelle et unique. Notre solution de surveillance front-end suit l'engagement des utilisateurs avec vos frontends JavaScript pour vous donner la possibilité de voir exactement ce que l'utilisateur a fait qui a conduit à une erreur.
LogRocket enregistre les journaux de la console, les temps de chargement des pages, les traces de pile, les requêtes/réponses réseau lentes avec les corps d'en-tête, les métadonnées du navigateur et les journaux personnalisés. Comprendre l'impact de votre code JavaScript n'aura jamais été aussi simple !
Essayez-le gratuitement.
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!