Parties précédentes :
Nous continuons à réfléchir à des approches pour rendre notre code propre, flexible et maintenable. Et maintenant, commençons à étudier des solutions architecturales propres.
Les principes SOLID ont été introduits par Robert C. Martin et sont largement considérés comme les meilleures pratiques en matière de programmation orientée objet et de conception de logiciels.
Dans cet article, nous examinerons les trois premiers principes SOLID : le principe de responsabilité unique, le principe ouvert/fermé et le principe de substitution de Liskov sur des exemples pratiques.
Une classe ne devrait avoir qu’une seule raison de changer
En d’autres termes, une classe ne devrait avoir qu’une seule responsabilité ou un seul travail. C’est important car une classe avec une seule responsabilité est plus facile à comprendre, à modifier et à maintenir. Les modifications apportées à une zone du code ne se répercuteront pas sur l'ensemble du système, ce qui réduit le risque d'introduction de bogues.
Jetons un coup d'œil à un exemple pratique et aux façons de suivre le principe de responsabilité unique :
// Bad class UserSettingsService { constructor(user: IUser) { this.user = user; } changeSettings(settings: IUserSettings): void { if (this.isUserValidated()) { // ... } } getUserInfo(): Promise<IUserSettings> { // ... } async isUserValidated(): Promise<boolean> { const userInfo = await this.getUserInfo(); // ... } }
Dans cet exemple, notre classe effectue des actions dans différentes directions : elle définit le contexte, le modifie et le valide.
Pour suivre le SRP, nous devons diviser ces différentes responsabilités.
// Better class UserAuth { constructor(user: IUser) { this.user = user; } getUserInfo(): Promise<IUserSettings> { // ... } async isUserValidated(): boolean { const userInfo = await this.getUserInfo(); // ... } } class UserSettings { constructor(user: IUser) { this.user = user; this.auth = new UserAuth(user); } changeSettings(settings: IUserSettings): void { if (this.auth.isUserValidated()) { // ... } } }
SRP vise à améliorer la modularité du code, en minimisant les défis liés aux interdépendances. En organisant le code en fonctions, en classes séparées et en favorisant la modularité, il devient plus réutilisable, ce qui permet de gagner du temps qui pourrait autrement être consacré à recoder les fonctionnalités existantes.
Les entités logicielles (classes, modules, fonctions) doivent être ouvertes à l'extension mais fermées à la modification
Vous devriez pouvoir ajouter de nouvelles fonctionnalités sans modifier le code existant. C'est important car en adhérant à ce principe, vous pouvez introduire de nouvelles fonctionnalités ou composants dans votre système sans risquer la stabilité du code existant.
Il favorise la réutilisabilité du code et réduit le besoin de modifications importantes lors de l'extension des fonctionnalités.
// Bad class Product { id: number; name: string[]; price: number; protected constructor(id: number, name: string[], price: number) { this.id = id; this.name = name; this.price = price; } } class Ananas extends Product { constructor(id: number, name: string[], price: number) { super(id, name, price); } } class Banana extends Product { constructor(id: number, name: string[], price: string) { super(id, name, price); } } class HttpRequestCost { constructor(product: Product) { this.product = product; } getDeliveryCost(): number { if (product instanceOf Ananas) { return requestAnanas(url).then(...); } if (product instanceOf Banana) { return requestBanana(url).then(...); } } } function requestAnanas(url: string): Promise<ICost> { // logic for ananas } function requestBanana(url: string): Promise<ICost> { // logic for bananas }
Dans cet exemple, le problème réside dans la classe HttpRequestCost
, qui, dans la méthode getDeliveryCost
, contient des conditions pour le calcul de différents types de produits, et nous utilisons des méthodes distinctes pour chaque type de produit. Donc, si nous devons ajouter un nouveau type de produit, nous devons modifier la classe HttpRequestCost
, et ce n'est pas sûr ; nous pourrions obtenir des résultats inattendus.
Pour l'éviter, nous devons créer une request
de méthode abstraite dans la classe Product
sans réalisations. La réalisation particulière aura hérité des classes : Ananas et Banane. Ils réaliseront eux-mêmes la demande.
HttpRequestCost
prendra le paramètre product
suivant l'interface de la classe Product
, et lorsque nous transmettrons des dépendances particulières dans HttpRequestCost
, il réalisera déjà la méthode request
pour lui-même.
// Better abstract class Product { id: number; name: string[]; price: string; constructor(id: number, name: string[], price: string) { this.id = id; this.name = name; this.price = price; } abstract request(url: string): void; } class Ananas extends Product { constructor(id: number, name: string[], price: string) { super(id, name, price); } request(url: string): void { // logic for ananas } } class Banana extends Product { constructor(id: number, name: string[], price: string) { super(id, name, price); } request(url: string): void { // logic for bananas } } class HttpRequestCost { constructor(product: Product) { this.product = product; } request(): Promise<void> { return this.product.request(url).then(...); } }
En suivant ce principe, vous réduirez le couplage de code et sauverez le système d'un comportement imprévisible.
Les objets d'une superclasse doivent pouvoir être remplacés par des objets de ses sous-classes sans interrompre l'application.
Pour comprendre ce principe, regardons cet exemple :
// Bad class Worker { work(): void {/../} access(): void { console.log('Have an access to closed perimeter'); } } class Programmer extends Worker { createDatabase(): void {/../} } class Seller extends Worker { sale(): void {/../} } class Designer extends Worker { access(): void { throwError('No access'); } }
Dans cet exemple, nous avons un problème avec la classe Contractor
. Designer
, Programmer
et Seller
sont tous des Workers et ont hérité de la classe parent Worker
. Mais en même temps, les concepteurs n’ont pas accès à un périmètre fermé car ils sont des entrepreneurs et non des employés. Et nous avons remplacé la méthode access
et brisé le principe de substitution de Liskov.
Ce principe nous dit que si nous remplaçons la superclasse Worker
par sa sous-classe, par exemple la classe Designer
, la fonctionnalité ne doit pas être interrompue. Mais si nous le faisons, la fonctionnalité de la classe Programmer
sera interrompue - la méthode access
aura des réalisations inattendues de la classe Designer
.
Suivant le principe de substitution de Liskov, nous ne devons pas réécrire les réalisations en sous-classes mais devons créer de nouvelles couches d'abstraction dans lesquelles nous définissons des réalisations particulières pour chaque type d'abstraction.
Corrigeons-le :
// Better class Worker { work(): void {/../} } class Employee extends Worker { access(): void { console.log('Have an access to closed perimeter'); } } class Contractor extends Worker { addNewContract(): void {/../} } class Programmer extends Employee { createDatabase(): void {/../} } class Saler extends Employee { sale(): void {/../} } class Designer extends Contractor { makeDesign(): void {/../} }
Nous avons créé de nouvelles couches d'abstractions Employee
et Contractor
, déplacé la méthode access
vers la classe Employee
et défini une réalisation spécifique. Si nous remplaçons la classe Worker
par la sous-classe Contractor
, la fonctionnalité Worker
ne sera pas interrompue.
Si vous adhérez à LSP, vous pouvez remplacer n'importe quelle instance de sous-classe partout où une instance de classe de base est attendue, et le programme devrait toujours fonctionner comme prévu. Cela favorise la réutilisation du code, la modularité et rend votre code plus résistant aux changements.
Dans le prochain article, nous examinerons les principes SOLID de ségrégation d'interface et d'inversion de dépendances.