Files
org-roamings/20220908193040-solid.org

9.3 KiB
Raw Blame History

SOLID

Introduction

  • Cinq principes de design de classes (P.O.O),
  • Principes décrits par Robert J. Martin (a.k.a Uncle Bob) en 2000,
  • Acronyme créé par Michael Feathers.

Principes

Responsabilité unique (Single responsability principle)

A class should have one and only one reason to change, meaning that a class should have only one job.

Exemple de classe réalisant différentes tâches:

  • Calcul du total de la facture (méthode calculate_total),
  • Impression de la facture (méthode print_invoice),
  • Sauvegarde de la facture (méthode save_to_file).
class Invoice:

    def __init__(self, book, quantity, discount_rate, tax_rate, total):
        self.book = book
        self.quantity = quantity
        self.discount_rate = discount_rate
        self.tax_rate = tax_rate
        self.total = self.calculate_total()

    def calculate_total(self):
        price = (self.book.price - self.book.price * self.discount_rate) * self.quantity
        price_with_taxe = price * (1 + self.tax_rate)
        return price_with_taxe

    def print_invoice(self):
        print(f'{self.quantity}x {self.name} {self.price} $')
        print(f'Discount Rate: {self.discount_rate}')
        print(f'Tax Rate: {self.tax_rate}')
        print(f'Total: {self.total}')

    def save_to_file(self):
        # Creates a file with given name and writes the invoice
        ...

Les erreurs :

  • La logique d'impression d'une facture (méthode print_invoice) devrait étre isolée : la seule raison pour laquelle la classe Invoice devrait étre mise à jour est le changement de logique de calul du total (méthode calculate_total).
  • La logique de sauvegarde d'une facture (méthode save_to_file) devrait elle aussi étre isolée : la classe Invoice ne devrait pas étre mise à jour suite au changement de méthode de sauvegarde (passage d'un fichier à une BDD par ex).

Une implémetation respectant le principe de responsabilité unique :

class Invoice:

    def __init__(self, book, quantity, discount_rate, tax_rate, total):
        self.book = book
        self.quantity = quantity
        self.discount_rate = discount_rate
        self.tax_rate = tax_rate
        self.total = self.calculate_total()

    def calculate_total(self):
        price = (self.book.price - self.book.price * self.discount_rate) * self.quantity
        price_with_taxe = price * (1 + self.tax_rate)
        return price_with_taxe


class InvoicePrinter:

    def __init__(self, invoice):
        self._invoice = invoice

    def print(self):
        print(f'{self._invoice.quantity}x {self._invoice.name} {self._invoice.price} $')
        print(f'Discount Rate: {self._invoice.discount_rate}')
        print(f'Tax Rate: {self._invoice.tax_rate}')
        print(f'Total: {self._invoice.total}')


class InvoicePersistance:

    def __init__(self, invoice):
        self._invoice = invoice

    def save_to_file(self):
        # Creates a file with given name and writes the invoice
        ...

Ouvert-fermé (Open-close principle)

Objects or entities should be open for extension but closed for modification.

Une classe doit étre ouverte pour l'extension et fermée pour les modifications :

  • Il doit étre possible d'ajouter une fonctionnalité à une classe sans modifier le code existant,
  • Réalisé à l'aide d'interfaces et classes virtuelles,

Exemple de classe ne respesctant pas le principe :

class InvoicePersistance:

    def __init__(self, invoice):
        self._invoice = invoice

    def save_to_file(self):
        # Creates a file with given name and writes the invoice
        ...

    def save_to_database(self):
        # Save the invoice to database
        ...

Il ne devrait pas étre nécessaire de modifier la classe InvoicePersistance lorsqu'une nouvelle manière de sauvegarder une Invoice est requise.

Une implémetation respectant le principe ouvert/fermé :

from abc import abstractmethod

class InvoicePersistance():

    def __init__(self, invoice):
        self._invoice = invoice

    @abstractmethod
    def save(self):
        ...


class FileInvoicePersistance(InvoicePersistance):

    def save(self):
        # Creates a file with given name and writes the invoice
        ...


class DatabaseInvoicePersistance(InvoicePersistance):

    def save(self):
        # Save the invoice to database
        ...

Principe de substitution de Liskov

Let q(x) be a property provable about objects of x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T.

Les classes filles doivent pouvoir être substituées par leur classe mère :

  • Pour une classe B, fille de A, il doit être possible de passer une instance de B à toute méthode s'attendant à avoir une instance de A, sans disfonctionnement.
class Rectangle:

    def __init__(self, width, height):
        self._width = width
        self._height = height

    @property
    def width(self):
        return self._width

    @width.setter
    def width(self, width):
        self._width = width

    @property
    def height(self):
        return self._height

    @height.setter
    def height(self, height):
        self._height = height

    def get_area(self):
       return self._width * self._height


class Square(Rectangle):

    def __init__(self, size):
        super().__init__(size, size)

    @Rectangle.width.setter
    def width(self, size):
        self._width = size
        self._height = size

    @Rectangle.height.setter
    def height(self, size):
        self._width = size
        self._height = size


def test_get_area(rectangle):
    width = rectangle.width
    rectangle.height = 10
    print(f'Expected area of {10 * width}, got {rectangle.get_area()}')

rectangle = Rectangle(2, 3)
test_get_area(rectangle)

square = Square(2)
square.width = 5
test_get_area(square)
Expected area of 20, got 20
Expected area of 50, got 100

La classe Square ne respecte pas le principe car le comportement de la méthode get_area diffère selon l'instance de classe passée en paramètre.

Principe de ségrégation d'interface (Interface segregation principle)

A client should never be forced to implement an interface that it doesnt use, or clients shouldnt be forced to depend on methods they do not use.

Il s'agit d'un principe de séparation des interfaces : plusieurs interfaces spécifiques sont meilleures qu'une unique générale. Ainsi, seules les méthodes nécessaires sont implémentées et non l'intégralité de celles définies par l'interface générique.

Exemple d'implémentation ne respectant pas le principe : l'interface ParkingLot englobe le stationnement ainsi que le paiement. Quid de son implémentation pour le cas d'un parking gratuit ? L'ideal serait d'isoler la partie paiement dans une interface dédiée.

from abc import abstractmethod

class Car:
    ...


class ParkingLot:

    @abstractmethod
    def park_car(self):
        # Decrease empty spot count by 1
        ...

    @abstractmethod
    def unpark_car(self):
        # Increase empty spot count by 1
        ...

    @abstractmethod
    def get_capacity(self):
        # Return car capacity
        ...

    @abstractmethod
    def calculate_fee(self, car):
        # Return the price based on the number of hours
        ...

    @abstractmethod
    def do_payment(self, car):
        # Do the payment process for the computed fee
        ...

class FreeParking(ParkingLot):

    def park_car(self):
        ...

    def unpark_car(self):
        ...

    def get_capacity(self):
        ...

    def calculate_fee(self, car):
        # Unused method here... the parking is free.
        ...

    def do_payment(self, car):
        # Unused method here... the parking is free.
        ...

Principe d'inversion de dépendence (Dependency inversion principle)

Entities must depend on abstractions, not on concretions. It states that the high-level module must not depend on the low-level module, but they should depend on abstractions.

Les classes devraient être baséees sur des interfaces ou des classes abstraites plutôt que des classes concrètes,

class SqlConnection:

    def connect(self):
        ...

class PasswordReminder:

    def __init__(self, connection: SqlConnection) -> None:
        ...

La classe PasswordReminder ne doit pas avoir de couplage fort avec la méthode employée pour obtenir le mot de passe. L'usage d'une interface permettrait d'abstraire la méthode de collecte.

from abc import abstractmethod

class Connection:

    @abstractmethod
    def connect(self):
        ...

class SqlConnection(Connection):

    def connect(self):
        ...

class PasswordReminder:

    def __init__(self, connection: Connection) -> None:
        ...