Les principes SOLID, ont été introduits pour la première fois dans les années 2000 par Robert C. Martin dans son article “Design Principles and Design Patterns (PDF)” et il en parle ensuite dans son livre “Clean Code: A Handbook of Agile Software Craftsmanship”. Ces principes ont été développés pour aider à concevoir du code plus maintenable, robuste et flexible.

Souvent associés à des langages orientés objet, on va voir aujourd’hui comment ils fonctionnent et comment on pourrait les appliquer au langage python.

Les 5 principes qui créent l’acronyme SOLID sont:

  1. Single Responsibility Principle (SRP)
  2. Open/Closed Principle (OCP)
  3. Liskov Substitution Principle (LSP)
  4. Interface Segregation Principle (ISP)
  5. Dependency Inversion Principle (DIP)

Single Responsibility Principle (SRP) :

Le Principe de Responsabilité Unique énonce qu’une classe ne devrait avoir qu’une seule responsabilité. Par exemple, si nous avons une classe qui gère la lecture et l’écriture dans un fichier, il serait préférable de la diviser en deux classes distinctes : une pour la lecture et une pour l’écriture.

Ce principe peut également s’étendre aux fonctions afin qu’elles soient moins complexes et plus lisibles.

Dans le cas suivant, nous avons une fonction qui a 2 responsabilités : la création de l’utilisateur et l’envoi de l’e-mail.

def create_user_and_send_email(user: User) -> Mapping:
    ...

Si on veut respecter le principe SRP, il nous faudrait 2 fonctions avec chacune sa responsabilité


def create_user(user: User):
    ...

def send_email(user: User):
    ...

Open/Closed Principle (OCP) :

Le Principe d’Ouverture/Fermeture préconise que les classes ou les fonctions doivent être ouvertes à l’extension, mais fermées à la modification. Ça limite le risque de générer des bugs en ajoutant des nouvelles fonctionnalités

Dans l’exemple suivant, nous avons une classe Widget qui ne respecte pas le principe OCP:

class Widget():
    def get_dashboard_widget_data(self, id: str) -> Mapping:
        ...

    def get_pdf_widget_data(self, id: str) -> Mapping:
        ...

Plutôt que d’utiliser des classes distinctes pour chaque type de widget, toutes les logiques de création des widget sont regroupées dans une seule classe. Pour ajouter un nouveau type de widget, il faut modifier la classe existante, violant ainsi le principe OCP. En appliquant l’OCP, nous pouvons diviser cette classe en deux qui pourraient hériter de Widget:

class Widget(ABC):
    
    @abstractmethod
    def get_data(self, id: str) -> Mapping:
        ...

class WidgetDashBoard(Widget):
    def get_data(self, id: str) -> Mapping:
        ...

class PDFWidget(Widget):
    def get_data(self, id: str) -> Mapping:
        ...

Ainsi si le besoin d’un nouveau type de widget arrive, les autres widgets ne seront pas impactés.

Liskov Substitution Principle (LSP) :

Le Principe de Substitution de Liskov stipule que les objets d’une classe dérivée doivent pouvoir remplacer les objets de la classe de base sans affecter la cohérence du programme. En Python, cela signifie que les sous-classes doivent être compatibles avec les classes de base

class Fluid():
    def get_meter():
        ...
    def get_consumption_m3()
        ...
class Water(Fluid):
    def get_meter():
        ...
    def get_consumption_m3()
        print("water consumption: 20m3")
class Electricity(Fluid):
    def get_meter():
        ...

    def get_consumption_m3()
        raise Exception("no m3 consumption for electricity")

Dans cet exemple la classe Electricity ne respecte pas le LSP car il ne respecte pas le contrat de la classe de base et a un comportement qui n’est pas cohérent, tandis que la classe Water le respecte. Pour des cas comme celui-ci il faudrait repenser la classe Fluid d’une manière plus générique, qui n’implique pas l’usage des unités par example get_consumption plutôt que get_consumption_m3 ou, en utilisant le principe ISP, faire 2 classes FluidKwh et FluidM3 par exemple.

Interface Segregation Principle (ISP) :

Le Principe de Ségrégation d’Interface déclare qu’une classe qui utilise une interface ne doit pas être forcée de dépendre des méthodes qu’elle n’utilise pas. Il faut éviter les interfaces trop larges et préférer les interfaces concises et plus petites.

class BuildingService(ABC):
    
    @abstractmethod
    def get_building_destination():
        ...
    
    @abstractmethod
    def get_consumption_electricty()
        ...
    
    @abstractmethod
    def get_consumption_water()
        ...
    
    @abstractmethod
    def get_consumption_fuel()
        ...

Dans cette classe, la méthode get_consumption_fuel pourra présenter un problème, car tous les bâtiments n’utilisent pas le fuel comme moyen de chauffage. Ainsi, toutes les classes qui vont l’utiliser vont être forcées d’implémenter cette méthode. Pour respecter l’ISP on pourrait créer une classe plus simple avec des méthodes plus génériques qui pourraient simplifier l’usage de l’interface et n’importe quelle classe pourrait l’implémenter

class BuildingService(ABC):
    
    @abstractmethod
    def get_building_destination():
        ...
    
    @abstractmethod
    def get_consumption(fluid):
        ...

Dependency Inversion Principle (DIP):

Le principe d’Inversion de Dépendance préconise que les modules de haut niveau ne devraient pas dépendre des modules de bas niveau, mais plutôt des abstractions. En Python, cela peut être réalisé en utilisant des interfaces ou des classes abstraites pour définir les contrats entre les différentes parties du code. Cela permet de réduire les dépendances directes et facilite le remplacement des composants sans affecter le reste du système.

Dans l’exemple suivant, MessageSender.message_service utilise la classe abstraite, MessageService, et non l’implémentation concrète :


class MessageService(ABC):
    @abstractmethod
    def send(self, message):
        ...

class MessageSender:
    def __init__(self, message_service: MessageService):
        self.message_service = message_service

    def send(self, message):
        self.message_service.send(message)

Quand on utilise une classe abstraite pour définir le contrat, ça nous permet d’utiliser toute implémentation de MessageService, par exemple pour les tests on pourrait envoyer une implémentation sous la forme d’un mock pour éviter la dépendance avec MessageService et ainsi pouvoir tester uniquement les méthodes de la classe MessageSender.

Autre exemple : si on a plusieurs implémentations de MessageService, on peut utiliser MessageSender sans que cela représente plusieurs implémentations de MessageSender.

Conclusion

Utiliser les principes SOLID n’est pas quelque chose de très compliqué et son usage n’est pas uniquement limité à des langages qui sont principalement orientés objets, comme c’est le cas des langages comme le Java. Avec SOLID on peut créer des systèmes plus flexibles, extensibles et faciles à maintenir. L’application cohérente de ces principes favorise la modularité, la réutilisabilité et la compréhensibilité du code, contribuant ainsi à la création de logiciels de haute qualité.

De mon point de vue, ce qui rend complexe l’usage de Python est qu’on peut se retrouver très facilement avec des fichiers ou des fonctions énormes qui cherchent à faire beaucoup de choses différentes. En utilisant les principes SOLID (notamment le Single Responsibility Principle et l’Open/Closed Principle), on est capable d’identifier les responsabilités de chacun des composants : réduisant ainsi la complexité et l’ajout potentiel de bugs lors des prochaines évolutions du code.

Je trouve aussi que les principes LSP, ISP, DIP sont plus difficiles à utiliser en python notamment, car la flexibilité du langage permet de créer des fonctions “libres”, qui n’appartient pas à une classe. En conséquence, quand on n’a pas eu d’expérience sur un langage plus strict, par exemple, on pourrait avoir plus de mal à identifier sur quels endroits on a besoin de créer un service et d’utiliser les principes, et sur quels, on peut se permettre d’utiliser juste des fonctions.

Dans des grandes bases des codes, utiliser ces principes même si dans une équipe les classes ne sont pas beaucoup utilisées, peut aider à avoir des bases de code qui sont compréhensibles facilement et des features scalables, en utilisant l’esprit de ces principes pour les utiliser sur des fonctions ou sur l’architecture du code.

Sources