Zasady SOLID


Wprowadzenie

Zbiór zasad regulujących sposób projektowania kodu w paradygmacie obiektowym. Wyróżniamy pięć zasad, które zdaniem Roberta Martina ,,pozwalają na projektowanie bardziej zrozumiałych i łatwiejszych w utrzymaniu programów”. W tym artykule pokrótce omówię każdą z tych zasad i zobrazuję je na przykładzie – dostarczania przesyłek różnymi środkami transportu.

Zasada pojedynczej odpowiedzialności

Zasada pojedynczej odpowiedzialności (Single Responsibility Principle, SRP) mówi, że klasa powinna mieć jeden i tylko jeden powód do zmiany. Nie chodzi o to, aby klasa miała jedną metodę, ale o to, aby odpowiadała za jeden obszar odpowiedzialności w systemie.

Oznacza to, że nie powinniśmy mieszać w jednej klasie logiki biznesowej, zapisu do bazy danych, wysyłania powiadomień czy logowania. Każda z tych rzeczy może zmieniać się z innego powodu.

Przykład złego podejścia – naruszenie SRP

Poniższa klasa odpowiada za zbyt wiele rzeczy:

  • realizację dostawy,
  • obliczanie kosztu transportu,
  • zapis do bazy danych,
  • wysyłanie powiadomienia e-mail.

Każda z tych funkcjonalności może zmienić się niezależnie od pozostałych, co oznacza wiele powodów do zmiany jednej klasy.

Zmiana sposobu zapisu do bazy, zmiana sposobu liczenia kosztów lub zmiana sposobu powiadamiania klienta wymusi modyfikację tej samej klasy.

Przykład dobrego podejścia – spełnienie SRP

Rozdzielamy odpowiedzialności na osobne klasy:

Teraz:

  • zmiana sposobu dostawy wpływa tylko na klasę LandShipment,
  • zmiana algorytmu liczenia kosztów wpływa tylko na ShipmentCostCalculator,
  • zmiana sposobu zapisu danych dotyczy tylko ShipmentRepository,
  • zmiana sposobu powiadamiania klienta dotyczy tylko NotificationService.

Każda klasa ma jeden powód do zmiany, dzięki czemu kod jest bardziej czytelny, łatwiejszy w utrzymaniu i rozwijaniu.

Zasada otwarte-zamknięte

Zasada otwarte-zamknięte (Open-closed principle, OCP) zwraca uwagę na to, że klasa powinna być otwarta na rozszerzanie, ale zamknięta na modyfikację istniejącego kodu. Dodawanie nowych funkcjonalności i ingerencja w istniejący kod zawsze niesie ze sobą ryzyko popełnienia błędu.

Rozszerzenie klasy powinno być realizowane poprzez dodanie nowej klasy. Przydaje się do tego wzorzec Strategia, który pozwala na wymianę klas, nie wpływając w żaden sposób na istniejący kod.

Poniższy przykład pokazuje różne rodzaje dostaw (lądowa, powietrzna, wodna), które realizowane są jako różne strategie. Możemy łatwo rozszerzyć serwis dostaw, dodając nowe środki transportu bez zmian w istniejącej implementacji.

Wynik działania:

Obiekt klasy ShipmentService najpierw przyjął pierwszą przesyłkę, która została dostarczona drogą lądową. Następnie przyjął drugą przesyłkę, którą dostarczono drogą powietrzną. W ten sposób realizujemy pewną strategię, w zależności od rodzaju przesyłki, serwis realizuje dostawę w odpowiedni sposób. Nic nie stoi na przeszkodzie, aby rozszerzyć wachlarz dostępnych sposobów dostawy.

Zasada podstawienia Liskov

Zasada podstawienia Liskov (Liskov Substitution Principle, LSP) mówi, że obiekty klas pochodnych powinny móc zastępować obiekty klasy bazowej (lub implementowanego interfejsu) bez zmiany poprawności działania programu.

Innymi słowy: jeżeli korzystamy z abstrakcji (np. interfejsu), to każda jego implementacja powinna zachowywać się zgodnie z oczekiwaniami wynikającymi z tej abstrakcji.

Przykład złego podejścia – naruszenie ISP

Załóżmy, że ShipmentService oczekuje, że każda implementacja DeliveryInterface zawsze poprawnie realizuje dostawę.

W tym przypadku ShipmentService zakłada, że metoda deliver() zawsze zwróci informację o dostawie. Jednak implementacja AirShipment zmienia to oczekiwane zachowanie, rzucając wyjątek.

Oznacza to, że nie możemy bezpiecznie podmienić LandShipment na AirShipment, mimo że obie klasy implementują ten sam interfejs. Program przestaje działać zgodnie z oczekiwaniami, co jest złamaniem zasady LSP.

Przykład dobrego podejścia – spełnienie LSP

Każda implementacja powinna zachowywać się zgodnie z kontraktem interfejsu i nie zmieniać oczekiwań wobec metody deliver().

Teraz każda klasa implementująca DeliveryInterface może zostać bezpiecznie użyta w ShipmentService, bez ryzyka zmiany logiki działania programu.

Zasada segregacji interfejsów

Zasada segregacji interfejsów (Interface segregation principle, ISP) zwraca uwagę na to, że lepiej posiadać więcej chudszych interfejsów, niż jeden gruby. Obszerny interfejs wypchany metodami do zaimplementowania przez klasy pochodne powoduje czasami implementacje tychże metod na siłę.

Rozwiązaniem jest tworzenie bardziej specyficznych interfejsów z niezbędnymi metodami i danymi wejściowymi.

Przykład złego podejścia – naruszenie LSP

Interfejs posiadający metody, które klasy implementujące nie będą w 100% realizować. Klasa BikeShipment nie będzie wykorzystywać metody refuel() i jej implementacja musiałaby np. rzucić wyjątek ,,Roweru nie można zatankować”.

Przykład dobrego podejścia – spełnienie ISP

Utworzenie kilku mniejszych interfejsów, które będą w pełni wykorzystywane przez konkretne klasy.

Zasada odwrócenia zależności

Zasada odwrócenia zależności (Dependency Inversion Principle, DIP) mówi, że:

moduły wysokiego poziomu nie powinny zależeć od modułów niskiego poziomu – obie grupy powinny zależeć od abstrakcji.

Oznacza to, że logika biznesowa (np. serwis realizujący dostawy) nie powinna znać szczegółów implementacyjnych takich jak konkretne środki transportu. Powinna operować na abstrakcjach, czyli interfejsach.

Przykład złego podejścia – naruszenie DIP

Serwis dostaw zależy bezpośrednio od konkretnej klasy LandShipment.

W tym przypadku:

  • moduł wysokiego poziomu (ShipmentService) zna szczegóły implementacyjne,
  • nie możemy użyć innego środka transportu bez modyfikacji tej klasy,
  • zależność biegnie od góry do dołu – od logiki biznesowej do detali.

Przykład dobrego podejścia – spełnienie DIP

Odwracamy zależność – ShipmentService zna tylko abstrakcję.

Teraz:

  • ShipmentService nie wie nic o LandShipment, AirShipment czy WaterShipment,
  • zależy wyłącznie od interfejsu,
  • to klasy niskiego poziomu (środki transportu) dostosowują się do abstrakcji.

Zależność została odwrócona – to implementacje zależą od interfejsu, a nie odwrotnie. Dzięki temu logika biznesowa jest całkowicie odseparowana od szczegółów technicznych.