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:
| |
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:
| |
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:
| |
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:
| |
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:
| |
| |
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:
| |
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:
| |
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:
| |
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.
| |
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ą.