Klasa, która zapisuje użytkownika w bazie danych, wysyła mu powitalny email i formatuje rachunek, jest jedną zmianą od zepsucia dwóch funkcji, z którymi nie ma nic wspólnego. Zmieniasz szablon emaila i ryzykujesz zapis do bazy. Zmieniasz układ rachunku i ryzykujesz email. SOLID to pięć zasad, które atakują ten rodzaj splątania z różnych stron, i łączy je jedna idea: klasa powinna mieć jeden powód do zmiany, a reszta kodu powinna zależeć od tego, co element obiecuje robić, nie od tego, jak to robi.

Zasady pochodzą z pracy Roberta C. Martina z 2000 roku, „Design Principles and Design Patterns". Michael Feathers ułożył je później w porządku, który tworzy akronim SOLID. Żadna z pięciu nie dotyczy składni czy konkretnego języka. Dotyczą tego, gdzie w kodzie przebiegają szwy, tak żeby zmiana w jednym miejscu zostawała w tym miejscu.

Co oznacza SOLID i czemu to pięć zasad, a nie jedna

  • S, Single Responsibility: klasa ma jeden powód do zmiany.
  • O, Open/Closed: dodawaj zachowanie przez dodawanie kodu, nie przez edycję kodu, który już działa.
  • L, Liskov Substitution: podklasa musi zachowywać się jak klasa bazowa wszędzie, gdzie klasa bazowa jest używana.
  • I, Interface Segregation: nie zmuszaj klasy do implementowania metod, których nie używa.
  • D, Dependency Inversion: zależ od abstrakcji, nie od konkretnej klasy.

Pokrywają się ze sobą celowo. Dependency Inversion sprawia, że Open/Closed działa w praktyce: nową implementację możesz dodać bez dotykania starego kodu tylko wtedy, gdy ten kod został napisany pod interfejs. Interface Segregation to Single Responsibility zastosowane do interfejsów, a nie do klas. Traktuj te pięć zasad jako perspektywy na jeden cel, nie jako pięć pól do odhaczenia.

Single Responsibility Principle: jeden powód do zmiany

Oto klasa z dwoma powodami do zmiany schowanymi w jednym ciele:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class User:
    def __init__(self, name: str, email: str):
        self.name = name
        self.email = email

    def save(self, db_connection) -> None:
        db_connection.execute(
            "INSERT INTO users (name, email) VALUES (?, ?)",
            (self.name, self.email),
        )

    def send_welcome_email(self, smtp_client) -> None:
        smtp_client.send(
            to=self.email,
            subject="Welcome",
            body=f"Hi {self.name}, thanks for signing up.",
        )

Zmiana schematu i zmiana szablonu emaila teraz obie dotykają User. Testowanie save() wymaga połączenia z bazą danych (albo jego mocka), a testowanie send_welcome_email() wymaga klienta SMTP, choć żadnemu z tych testów nie zależy na drugiej metodzie. Tu też zaczyna się splątanie opisane w Czym Jest Spaghetti Code i Jak Go Zrefaktoryzować: klasa wchłania coraz więcej niezwiązanych zadań, bo już tam była.

Rozdziel zadania:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class User:
    def __init__(self, name: str, email: str):
        self.name = name
        self.email = email


class UserRepository:
    def save(self, user: User, db_connection) -> None:
        db_connection.execute(
            "INSERT INTO users (name, email) VALUES (?, ?)",
            (user.name, user.email),
        )


class WelcomeEmailer:
    def send(self, user: User, smtp_client) -> None:
        smtp_client.send(
            to=user.email,
            subject="Welcome",
            body=f"Hi {user.name}, thanks for signing up.",
        )

Teraz każda klasa zmienia się z własnego powodu, i każdą można przetestować fejkiem, który obejmuje jedno małe zadanie. „Jeden powód do zmiany" to nie „jedna metoda". UserRepository mogłoby rozrosnąć się o find_by_email i delete i nadal mieć jedną odpowiedzialność, bo każda metoda służy temu samemu zadaniu: zapisywaniu użytkowników.

Open/Closed Principle: dodawanie zachowania bez edycji działającego kodu

Kalkulator, który rozgałęzia się na podstawie tagu typu:

1
2
3
4
5
6
7
class AreaCalculator:
    def area(self, shape: dict) -> float:
        if shape["type"] == "circle":
            return 3.14159 * shape["radius"] ** 2
        elif shape["type"] == "rectangle":
            return shape["width"] * shape["height"]
        raise ValueError(f"unknown shape: {shape['type']}")

Dodanie trójkąta oznacza ponowną edycję area(). Co gorsza, każda inna funkcja rozgałęziająca się na shape["type"] musi zostać znaleziona i zmieniona tak samo. Każdy nowy kształt to zmiana w już przetestowanym kodzie, z ryzykiem zepsucia przypadków koła i prostokąta przy okazji.

Zdefiniuj kontrakt raz i dodawaj nowe kształty jako nowe klasy:

 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
from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self) -> float: ...


class Circle(Shape):
    def __init__(self, radius: float):
        self.radius = radius

    def area(self) -> float:
        return 3.14159 * self.radius ** 2


class Rectangle(Shape):
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

    def area(self) -> float:
        return self.width * self.height


def total_area(shapes: list[Shape]) -> float:
    return sum(shape.area() for shape in shapes)

Dodanie Triangle to nowa klasa implementująca area(). total_area nigdy się nie zmienia, podobnie Circle i Rectangle. To wszystko, co znaczy „otwarte na rozszerzenia, zamknięte na modyfikacje": nowe zachowanie przychodzi jako nowy kod, a kod już objęty testami zostaje nietknięty.

Liskov Substitution Principle: podklasa musi utrzymać obietnice klasy bazowej

Klasyczna porażka to Square zbudowany jako podklasa Rectangle:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Rectangle:
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

    def set_width(self, width: float) -> None:
        self.width = width

    def set_height(self, height: float) -> None:
        self.height = height

    def area(self) -> float:
        return self.width * self.height


class Square(Rectangle):
    def set_width(self, width: float) -> None:
        self.width = width
        self.height = width

    def set_height(self, height: float) -> None:
        self.width = height
        self.height = height
1
2
3
4
def resize_and_check(rect: Rectangle) -> None:
    rect.set_width(5)
    rect.set_height(4)
    assert rect.area() == 20

resize_and_check działa dla każdego Rectangle. Podaj mu Square, a asercja się nie powiedzie: ustawienie wysokości po cichu zmieniło też szerokość, więc powierzchnia wynosi 16, nie 20. Square przechodzi kontrolę typów jako Rectangle, więc mypy nie ma nic do zgłoszenia, a jednak łamie zachowanie, którego każdy kod wywołujący Rectangle miał prawo się spodziewać. Zapamiętaj to rozróżnienie. Naruszenia Liskov rzadko dotyczą sygnatur metod, które narzędzia i tak już wyłapują. Dotyczą podklasy, która po cichu zmienia to, co kod wywołujący ma prawo zakładać.

Nie naprawiaj tego specjalnym przypadkiem dla kwadratów wewnątrz resize_and_check. Napraw to tak, by Square nie było podklasą Rectangle. Kwadrat nie ma dwóch niezależnie zmienianych wymiarów, więc nie może honorować kontraktu Rectangle. Obie klasy mogą implementować interfejs Shape z przykładu Open/Closed, bez żadnego dziedziczenia między nimi.

Interface Segregation Principle: nie wymuszaj na klasie nieużywanych metod

Interfejs, który łączy niezwiązane ze sobą zdolności:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
from abc import ABC, abstractmethod

class Printer(ABC):
    @abstractmethod
    def print(self, document) -> None: ...

    @abstractmethod
    def scan(self, document) -> None: ...

    @abstractmethod
    def fax(self, document) -> None: ...


class OldInkjetPrinter(Printer):
    def print(self, document) -> None:
        ...  # to naprawdę działa

    def scan(self, document) -> None:
        raise NotImplementedError("this printer cannot scan")

    def fax(self, document) -> None:
        raise NotImplementedError("this printer cannot fax")

Każdy kod, który ma w ręku Printer, musi teraz wiedzieć, poza systemem typów, które metody są bezpieczne na której instancji. Interfejs obiecuje trzy zdolności, a dostarcza jedną, plus dwie miny.

Podziel interfejs według zdolności, nie według urządzenia:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class Printable(ABC):
    @abstractmethod
    def print(self, document) -> None: ...

class Scannable(ABC):
    @abstractmethod
    def scan(self, document) -> None: ...

class Faxable(ABC):
    @abstractmethod
    def fax(self, document) -> None: ...


class OldInkjetPrinter(Printable):
    def print(self, document) -> None: ...


class AllInOnePrinter(Printable, Scannable, Faxable):
    def print(self, document) -> None: ...
    def scan(self, document) -> None: ...
    def fax(self, document) -> None: ...

Funkcja, która potrzebuje tylko drukować, przyjmuje Printable, i nie ma żadnego .scan(), które można przez przypadek wywołać na urządzeniu, które nigdy go nie miało. Kontrakt wynika z tego, co obiekt implementuje, nie z komentarza ostrzegającego, które metody rzucą wyjątkiem.

Dependency Inversion Principle: zależ od abstrakcji, nie od konkretnych klas

Serwis zbudowany bezpośrednio na bibliotece bazy danych:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class SQLiteUserRepository:
    def save(self, user) -> None:
        ...  # kod specyficzny dla sqlite3


class UserService:
    def __init__(self):
        self.repository = SQLiteUserRepository()

    def register(self, user) -> None:
        self.repository.save(user)

UserService jest zespolony z SQLite. Przejście na Postgres albo podanie fałszywego repozytorium w teście oznacza edycję samego UserService zamiast podmiany zależności.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from abc import ABC, abstractmethod

class UserRepository(ABC):
    @abstractmethod
    def save(self, user) -> None: ...


class SQLiteUserRepository(UserRepository):
    def save(self, user) -> None:
        ...  # kod specyficzny dla sqlite3


class UserService:
    def __init__(self, repository: UserRepository):
        self.repository = repository

    def register(self, user) -> None:
        self.repository.save(user)

UserService zależy teraz od abstrakcji UserRepository. Zbudowanie SQLiteUserRepository(), albo FakeUserRepository() do testu, i podanie go to osobny krok, i ten krok to dependency injection. Te dwa terminy używane są wymiennie, choć to różne rzeczy: dependency inversion to zasada projektowa (zależ od abstrakcji), a dependency injection to technika, która dostarcza konkretny obiekt. Czym Jest Dependency Injection? (Z Przykładami) opisuje stronę injection, w tym to, co dodaje kontener DI, gdy klasa potrzebuje więcej niż jednej lub dwóch podłączonych zależności.

Kiedy SOLID staje się over-engineeringiem

Rozbicie dwudziestoliniowego skryptu na pięć interfejsów nie daje nic, jeśli istnieje dokładnie jedna implementacja i nie ma planu na drugą. Interface Segregation i Dependency Inversion obie dodają warstwę pośrednictwa. Ta warstwa się opłaca, gdy naprawdę podmieniasz implementacje, jak produkcja kontra testy albo SQLite kontra Postgres, albo gdy musisz izolować klasę za test double, jak opisano w Testy Jednostkowe vs Integracyjne vs End-to-End. Jeśli nic nigdy nie jest podmieniane, abstrakcja to czysty narzut.

Więcej abstrakcji nie znaczy lepszego projektu. Konstruktor, który przyjmuje osiem wstrzykiwanych interfejsów, nie jest bardziej SOLID niż taki, który przyjmuje trzy. Znaczy to, że klasa za nim wciąż ma za dużo odpowiedzialności, co odsyła cię do pierwszej litery.

SOLID to też słownictwo obiektowe. Dużo dobrego kodu nie ma żadnych klas. Nawyki, które za tym stoją, i tak się liczą: małe funkcje z jednym zadaniem, i zależności podawane, nie ściągane z globalnych zmiennych. Ma to znaczenie też w stylu funkcyjnym, nawet gdy żadna z pięciu liter nie stosuje się dosłownie.

Jak zacząć stosować SOLID w istniejącym kodzie

Nie przebudowuj całego kodu pod checklistę. Zastosuj najpierw Single Responsibility, i tylko tam, gdzie klasa już boli przy zmianie: plik, który powoduje konflikty merge co sprint, albo test, który potrzebuje trzech niezwiązanych mocków, zanim ruszy. Postaw zależność za abstrakcją tylko wtedy, gdy druga implementacja albo fejk do testów jest potrzebą bieżącą, nie hipotetyczną. Większość problemów z Open/Closed i Liskov zaczyna się jako problem Single Responsibility, z którego wyrosła podklasa, więc pozostałe trzy zasady zwykle przychodzą same, gdy te dwa nawyki się utrwalą.

Powiązane artykuły