Introduction
SOLID est un acronyme mnémotechnique pour cinq principes de conception orientée objet qui ont été introduits par Robert C. Martin, communément appelé Uncle Bob. Ces principes sont destinés à aider les concepteurs, architectes, ingénieurs et développeurs de logiciels à créer des logiciels plus flexibles, maintenables et évolutifs. En suivant ces principes, vous pouvez concevoir des classes plus faciles à tester, refactoriser, réutiliser et étendre.
L'acronyme SOLID signifie :
S – Principe de responsabilité unique
O – Principe ouvert-fermé
L – Principe de substitution de Liskov
I – Principe de ségrégation des interfaces
D – Principe d'inversion des dépendances
Dans cet article, nous expliquerons chaque principe individuellement pour comprendre comment il peut vous aider à écrire un meilleur code. De plus, nous ajouterons des extraits de code pour chaque principe afin de vous montrer comment les appliquer dans votre parcours de codage, ainsi que ce que vous devriez éviter dans une architecture de code propre. Pour illustrer ces concepts, nous utiliserons le langage de programmation Kotlin développé par JetBrains et des contributeurs open-source.
Principe de responsabilité unique
Le principe de responsabilité unique (SRP) est un principe de conception logicielle qui stipule que chaque classe ou module d'un programme doit avoir une seule responsabilité bien définie. Cela signifie qu'une classe ne doit avoir qu'une seule raison de changer, et qu'elle ne doit être responsable que d'une seule partie des fonctionnalités du programme.
Considérez l'extrait de code suivant comme un exemple de la façon dont ce principe peut être appliqué en Kotlin:
|
1 2 3 4 5 6 7 8 9 |
data class User( val firstName: String, val lastName: String, val email: String ) |
Le mot-clé data en Kotlin indique que cette classe est une classe de données (data class), ce qui signifie qu'elle est destinée à contenir des données et n'a pas de comportement complexe. En utilisant cette classe de données, nous pouvons créer une classe UserService pour gérer les utilisateurs, comme indiqué ci-dessous :
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class UserService { fun createUser(user: User) { // code pour créer un utilisateur dans la base de données } fun deleteUser(user: User) { // code pour supprimer un utilisateur de la base de données } fun updateUser(user: User) { // code pour mettre à jour un utilisateur dans la base de données } fun getUser(id: Int): User { // code pour récupérer un utilisateur à partir de la base de données } } |
Dans cet exemple, la classe UserService a une seule responsabilité : la gestion des utilisateurs dans une base de données. Chaque utilisateur est représenté par la data class User partagée précédemment. Toutes les méthodes de la classe UserService sont liées à cette responsabilité et sont donc cohérentes. Cela rend la classe plus facile à comprendre et à maintenir, car il est clair que toutes les méthodes de la classe sont liées à une tâche unique et bien définie.
Une méthode qui viole le principe de responsabilité unique (SRP) dans la classe UserService serait une méthode qui n'est pas liée à la responsabilité principale de la classe, à savoir la gestion des utilisateurs dans une base de données. Par exemple, considérez la variante suivante de la classe UserService :
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class UserService { fun createUser(user: User) { // code pour créer un utilisateur dans la base de données } fun deleteUser(user: User) { // code pour supprimer un utilisateur de la base de données } fun updateUser(user: User) { // code pour mettre à jour un utilisateur dans la base de données } fun getUser(id: Int): User { // code pour récupérer un utilisateur de la base de données } fun sendEmail(user: User, subject: String, message: String) { // code pour envoyer un e-mail à l'utilisateur } } |
Dans cet exemple, la sendEmail méthode ne concerne pas la responsabilité principale de la UserService classe, qui consiste à gérer les utilisateurs dans une base de données. Cette méthode est responsable de l'envoi d'e-mails, ce qui est une préoccupation distincte de la gestion des utilisateurs. Par conséquent, cette méthode viole le SRP, car elle introduit une deuxième raison de modifier la classe UserService.
Pour respecter le principe de responsabilité unique (SRP), il serait préférable de séparer la fonctionnalité d'envoi d'e-mails dans une classe distincte, telle qu'une EmailService classe. Cela permettrait à la UserService classe de se concentrer sur sa responsabilité principale de gestion des utilisateurs, et à la EmailService classe de se concentrer sur sa responsabilité d'envoi d'e-mails.
Il convient de noter que le principe de responsabilité unique ne concerne pas le nombre de méthodes d'une classe, mais plutôt la cohésion des méthodes et la séparation claire des responsabilités au sein d'une classe.
Principe ouvert-fermé
Le principe ouvert-fermé (OCP) est un principe de conception logicielle qui stipule que les entités logicielles (telles que les classes, les modules ou les fonctions) doivent être ouvertes à l'extension, mais fermées à la modification. Cela signifie qu'il doit être possible d'ajouter de nouvelles fonctionnalités à une classe ou à un module sans modifier son code existant.
Considérez la UserService classe de l'exemple précédent. Supposons que nous voulions ajouter une nouvelle fonctionnalité à la UserService classe qui nous permet de rechercher des utilisateurs par adresse e-mail. Une façon de procéder consisterait à ajouter une nouvelle méthode à la UserService classe comme mis en évidence dans le code ci-dessous :
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class UserService { fun createUser(user: User) { // code pour créer un utilisateur dans la base de données } fun deleteUser(user: User) { // code pour supprimer un utilisateur de la base de données } fun updateUser(user: User) { // code pour mettre à jour un utilisateur dans la base de données } fun getUser(id: Int): User { // code pour récupérer un utilisateur de la base de données } fun searchUsersByEmail(email: String): List<User> { // code pour rechercher des utilisateurs par adresse e-mail } } |
Cette approche fonctionne, mais elle viole le principe ouvert-fermé, car nous avons dû modifier la classe existante UserService afin d'ajouter la nouvelle fonctionnalité. Une meilleure approche consisterait à utiliser l'héritage ou la composition pour étendre les fonctionnalités de la classe UserService sans modifier son code.
Pour y parvenir, nous pourrions créer une nouvelle classe appelée UserSearchService qui étend la classe UserService et ajoute la fonctionnalité de recherche par e-mail :
|
1 2 3 4 5 6 7 8 9 |
class UserSearchService(private val userService: UserService) : UserService() { fun searchUsersByEmail(email: String): List<User> { // code pour rechercher des utilisateurs par adresse e-mail } } |
Dans cet exemple, la UserSearchService classe est ouverte à l'extension, car elle fournit des fonctionnalités supplémentaires au-delà de ce qui est proposé par la UserService classe. En même temps, la UserService classe reste fermée à la modification, car nous n'avons pas eu besoin de modifier son code afin d'ajouter la fonctionnalité de recherche par e-mail.
Principe de substitution de Liskov
Le principe de substitution de Liskov (LSP) est un principe de conception logicielle qui stipule que les objets d'une superclasse doivent pouvoir être remplacés par des objets d'une sous-classe sans affecter la correction du programme. Cela signifie qu'une sous-classe doit être un substitut valide pour sa superclasse, et doit se comporter de la même manière que la superclasse lorsqu'elle est utilisée dans le même contexte.
Nous allons continuer la démonstration en utilisant la User et la UserService classes des exemples précédents. Pour permettre à la classe User d'être étendue, nous utilisons le mot-clé open en Kotlin :
|
1 2 3 4 5 6 7 8 9 |
open class User( val firstName: String, val lastName: String, val email: String ) |
Voici la classe originale UserService qui utilise la classe de données User ci-dessus :
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class UserService { fun createUser(user: User) { // code pour créer un utilisateur dans la base de données } fun deleteUser(user: User) { // code pour supprimer un utilisateur de la base de données } fun updateUser(user: User) { // code pour mettre à jour un utilisateur dans la base de données } fun getUser(id: Int): User { // code pour récupérer un utilisateur de la base de données } } |
Supposons que nous voulions créer une sous-classe de User appelée AdminUser qui représente les utilisateurs ayant des privilèges administratifs. Nous pourrions faire cela ainsi :
|
1 2 3 4 5 6 7 8 9 |
class AdminUser( firstName: String, lastName: String, email: String, ) : User(firstName, lastName, email) |
Dans cet exemple, la classe AdminUser est un substitut valide pour la classe User , car elle se comporte de la même manière que la classe User et peut être utilisée partout où un objet User est attendu. Par exemple, nous pouvons utiliser la classe AdminUser avec la classe UserService comme ceci :
|
1 2 3 4 5 6 7 8 9 |
fun main() { val userService = UserService() val adminUser = AdminUser("John", "Doe", "john.doe@example.com") userService.createUser(adminUser) } |
Ce code est correct, car la classe AdminUser est un substitut valide pour la classe User et peut être utilisée de la même manière qu'un objet User .
Il’s important de noter que le principe de substitution de Liskov va au-delà de la simple héritabilité. Il’s s'agit de s'assurer que les objets d'une sous-classe se comportent de la même manière que les objets de la superclasse, quelle que soit la façon dont la sous-classe est implémentée. Par exemple, si la classe AdminUser devait se comporter différemment de la classe User d'une manière ou d'une autre, cela violerait le principe de substitution de Liskov, car elle ne serait pas un substitut valide pour la classe User .
Principe de ségrégation des interfaces
Le principe de ségrégation des interfaces (ISP) est un principe de conception logicielle qui stipule que les clients ne doivent pas être forcés de dépendre d' interfacesqu'ils n'utilisent pas. Cela signifie qu'il est généralement judicieux de créer de petites interfaces ciblées qui font bien une seule chose, plutôt que de créer de grandes interfaces d'usage général qui tentent de faire beaucoup de choses.
Voici un exemple de la façon dont ce principe peut être appliqué en réécrivant les classes User et UserService des exemples précédents :
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
interface UserService { fun createUser(user: User) fun deleteUser(user: User) fun updateUser(user: User) fun getUser(id: Int): User } class DatabaseUserService : UserService { override fun createUser(user: User) { // code pour créer un utilisateur dans la base de données } override fun deleteUser(user: User) { // code pour supprimer un utilisateur de la base de données } override fun updateUser(user: User) { // code pour mettre à jour un utilisateur dans la base de données } override fun getUser(id: Int): User { // code pour récupérer un utilisateur de la base de données } } |
Dans cet exemple, l'interface UserService définit quatre méthodes liées à la gestion des utilisateurs dans une base de données. La classe DatabaseUserService implémente cette interface et fournit des implémentations concrètes pour ces méthodes.
Supposons que nous voulions ajouter une nouvelle fonctionnalité à l'interface UserService qui nous permet de rechercher des utilisateurs par adresse e-mail. Une façon de procéder consisterait à ajouter une nouvelle méthode à l'interface UserService :
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
interface UserService { fun createUser(user: User) fun deleteUser(user: User) fun updateUser(user: User) fun getUser(id: Int): User fun searchUsersByEmail(email: String): List<User> } |
Votre code ne s'exécutera pas, à moins que vous n'implémentiez également cette méthode dans toutes les classes qui implémentent l'interface UserService :
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class DatabaseUserService : UserService { override fun createUser(user: User) { // code pour créer un utilisateur dans la base de données } override fun deleteUser(user: User) { // code pour supprimer un utilisateur de la base de données } override fun updateUser(user: User) { // code pour mettre à jour un utilisateur dans la base de données } override fun getUser(id: Int): User { // code pour récupérer un utilisateur de la base de données } override fun searchUsersByEmail(email: String): List<User> { // code pour rechercher des utilisateurs par adresse e-mail } } |
Bien que cette approche fonctionne, elle viole le principe de ségrégation des interfaces, car elle oblige la classe DatabaseUserService à implémenter une méthode ( searchUsersByEmail ) dont elle n'a peut-être pas besoin ou qu'elle n'utilise pas.
Une meilleure approche consisterait à créer une interface séparée pour la fonctionnalité de recherche par e-mail :
|
1 2 3 4 5 |
interface UserSearchService { fun searchUsersByEmail(email: String): List<User> } |
Nous avons maintenant des interfaces distinctes, petites et ciblées, c'est-à-dire UserService et UserSearchServicequi ont une responsabilité unique. Une classe nécessitant toutes les fonctionnalités de ces interfaces peut les implémenter comme indiqué dans l'extrait de code ci-dessous :
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class DatabaseUserService : UserService, UserSearchService { override fun createUser(user: User) { // code pour créer un utilisateur dans la base de données } override fun deleteUser(user: User) { // code pour supprimer un utilisateur de la base de données } override fun updateUser(user: User) { // code pour mettre à jour un utilisateur dans la base de données } override fun getUser(id: Int): User { // code pour récupérer un utilisateur depuis la base de données } override fun searchUsersByEmail(email: String): List<User> { // code pour rechercher des utilisateurs par adresse e-mail } } |
Cela respecte le principe de ségrégation des interfaces, car cela garantit que les clients (comme la classe DatabaseUserService ) ne sont pas obligés de dépendre d'interfaces qu'ils n'utilisent pas.
Pour mieux comprendre ce concept, supposons que nous ayons une autre classe appelée MemoryUserService qui implémente l'interface UserService mais qui n'a pas besoin de la fonctionnalité de recherche par e-mail, nous pouvons écrire le code ainsi :
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
class MemoryUserService : UserService { private val users = mutableListOf<User>() override fun createUser(user: User) { users.add(user) } override fun deleteUser(user: User) { users.remove(user) } override fun updateUser(user: User) { val index = users.indexOf(user) if (index >= 0) { users[index] = user } } override fun getUser(id: Int): User { return users[id] } } |
Dans cet exemple, la classe MemoryUserService a seulement besoin d'implémenter les méthodes définies dans l'interface UserService , et n'a pas besoin de se soucier de la fonctionnalité de recherche par e-mail. Cela permet à la classe MemoryUserService de se concentrer sur sa responsabilité principale de gestion des utilisateurs en mémoire, plutôt que d'être contrainte d'implémenter des fonctionnalités non liées.
Principe d'inversion des dépendances
Le principe d'inversion des dépendances (DIP) est un principe de conception logicielle qui stipule que les modules de haut niveau ne doivent pas dépendre des modules de bas niveau, mais que les deux doivent dépendre d'abstractions. Cela signifie qu'il est généralement judicieux de concevoir votre logiciel de manière à ce que les composants de haut niveau ne soient pas liés à des implémentations spécifiques de composants de bas niveau, mais dépendent plutôt d'abstractions (telles que des interfaces ou des classes abstraites) qui peuvent être implémentées de différentes manières.
Voyons un exemple de la manière dont ce principe peut être appliqué aux classes User et UserService utilisées dans les extraits de code précédents :
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
interface UserRepository { fun create(user: User) fun delete(user: User) fun update(user: User) fun get(id: Int): User } class UserService(private val repository: UserRepository) { fun createUser(user: User) { repository.create(user) } fun deleteUser(user: User) { repository.delete(user) } fun updateUser(user: User) { repository.update(user) } fun getUser(id: Int): User { return repository.get(id) } } |
Dans cet exemple, la classe UserService dépend d’une abstraction appelée l’interface UserRepository, plutôt que de dépendre d’une implémentation spécifique d’un dépôt d’utilisateurs. Cela nous permet d’implémenter l’interface UserRepository de différentes manières, comme avec une base de données ou un stockage en mémoire, sans affecter la classe UserService.
Par exemple, voici une implémentation de l’interface UserRepository qui utilise une base de données :
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class DatabaseUserRepository: UserRepository { override fun create(user: User) { // code pour créer un utilisateur dans la base de données } override fun delete(user: User) { // code pour supprimer un utilisateur de la base de données } override fun update(user: User) { // code pour mettre à jour un utilisateur dans la base de données } override fun get(id: Int): User { // code pour récupérer un utilisateur depuis la base de données } } |
Voici une autre implémentation de l’interface UserRepository qui utilise un stockage en mémoire :
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
class MemoryUserRepository : UserRepository { private val users = mutableListOf<User>() override fun create(user: User) { users.add(user) } override fun delete(user: User) { users.remove(user) } override fun update(user: User) { val index = users.indexOf(user) if (index >= 0) { users[index] = user } } override fun get(id: Int): User { return users[id] } } |
Cela rend le système plus flexible et plus facile à maintenir, car cela nous permet de modifier l’implémentation du dépôt sans affecter le reste du système. Cela facilite également le test de la classe UserService, car nous pouvons simuler la dépendance UserRepository dans nos tests.
Conclusion
Dans cet article, nous avons parlé des cinq principes de SOLID du code et des extraits de code partagés respectant chaque principe. Respecter ces principes peut vous aider à concevoir des systèmes logiciels plus flexibles, faciles à maintenir et évolutifs. Cependant, il est important de garder à l'esprit que ces principes sont des lignes directrices, plutôt que des règles strictes, et c’est au développeur de décider quand et comment les appliquer dans le contexte de son projet spécifique. Continuez votre apprentissage en consultant notre blog pour des articles plus approfondis et actualisés et des tutoriels sur le cloud computing et le DevOps, la conception et le développement de logiciels, les tendances technologiques à surveiller, et plus encore.
Bon code !
Commentaires
Aucun commentaire pour l'instant. Soyez le premier.