SOLID – pragmatycznie

Na przestrzeni lat powstało bardzo dużo projektów. Część z nich była łatwiejsza w utrzymaniu, część trudniejsza. Analiza tych projektów pozwoliła zauważyć, że są pewne zasady, które powodują łatwiejszy ich rozwój. Te zasady zostały połączone w zbiory zasad. Najbardziej popularnym i powszechnie stosowanym zbiorem zasad jest SOLID:

  • SRP – Single responsibility principle (Zasada pojedynczej odpowiedzialności)
  • OCP – Open/closed principle (Zasada otwarty/zamknięty)
  • LSP – Liskov substitution principle (Zasada podstawienia Liskov)
  • ISP – Interface segregation principle (Zasada segregacji interfejsów)
  • DIP – Dependency inversion principle (Zasada odwracania zależności)

Postaram się opisać te zasady z pragmatycznego punktu widzenia.

Zasada pojedynczej odpowiedzialności

Klasa powinna mieć tylko jedną odpowiedzialność. Nigdy nie powinien istnieć więcej niż jeden powód do modyfikacji klasy.

Zasada wydaje się dość prosta. Jeśli jednak zaczniemy ją stosować w praktyce, zaraz okaże się, że dość ciężko jest jednoznacznie określić czym jest „pojedyncza odpowiedzialność”. Co więcej, trudno jest przewidzieć, jakie w przyszłości będziemy dokonywać modyfikacje, a co za tym idzie może okazać się niemożliwe napisanie kodu tak, aby zawsze istniał tylko jeden powód do modyfikacji danej klasy.

Jak w związku z tym możemy stosować tę zasadę? Odpowiedź jest dość enigmatyczna – trzeba zaufać intuicji i doświadczeniu.

Jeśli tworzyliśmy projekty na tyle długo, że musieliśmy je rozwijać, to powinniśmy być w stanie mniej więcej przewidzieć, jakie kolejne modyfikacje mogą się pojawić. Ponadto, jeśli tworzymy projekt, który jest podobny do jakiegoś innego, bardzo prawdopodobne jest, że u nas pojawią się podobne funkcjonalności i nasz kod powinien być na nie przygotowany. W takich sytuacjach świetnie sprawdza się doświadczenie. Jeśli my go nie mamy, to może mają je nasi koledzy z zespołu. Dlatego warto słuchać rad i gdy ktoś nam mówi, że nasz kod może mieć więcej niż jedną odpowiedzialność, warto się nad tym zastanowić, a nie ślepo bronić swoich racji. A co gdy tworzymy coś zupełnie nowego? Wtedy trzeba zaufać intuicji – swojej lub czyjejś.

Zasada otwarty/zamknięty

Klasy powinny być otwarte na rozszerzenia i zamknięte na modyfikacje.

Ta zasada może spowodować najwięcej problemów w naszym kodzie. Jeśli zastosujemy ją w nieodpowiednim miejscu, możemy doprowadzić do nadmiernego skomplikowania naszego kodu (overengineering). Jeśli jednak jej nie zastosujemy w miejscu, w którym powinniśmy, może to skutkować kodem, który jest trudny w zrozumieniu, utrzymaniu i rozwoju.

Napisanie kodu, który spełnia tę zasadę, to np. wprowadzenie wzorca strategii albo mechanizmu wtyczek (plugins). Kod, który nie spełnia tej zasady, to np. kod z „ifami” i „switchami”. Nie zawsze pierwsze podejście jest dobre i nie zawsze drugie podejście jest złe. Gdy piszemy mechanizm wyliczania końcowej kwoty na fakturze z uwzględnieniem rabatów, może warto iść w pierwsze rozwiązania, bo w przyszłości mogą pojawić się kolejne sposoby wyliczania tej kwoty i kolejne rabaty. Jeśli jednak piszemy obsługę świateł drogowych, to cóż… raczej nigdy nie będziemy mieli więcej niż 3 światła drogowe, więc nie warto niepotrzebnie komplikować nasz kod i lepiej jest zastosować drugie rozwiązanie.

Zasada podstawienia Liskov

Funkcje, które używają klas bazowych, muszą być w stanie używać również obiektów klas dziedziczących po klasach bazowych, bez dokładnej znajomości tych obiektów.

Jeśli chodzi o tę zasadę, to mamy dwa przypadki. Pierwszy z nich, to gdy dziedziczymy po jakiejś klasie lub implementujemy jakiś interfejs, to żadna z metod nie powinna rzucać wyjątku w stylu „NotImplementedException”. Powinniśmy zaimplementować wszystkie metody. Drugi z nich to nigdy nie powinna istnieć potrzeba rzutowania klasy (lub interfejsu) na jakąś inną klasę (lub interfejs). Nie powinna nawet istnieć potrzeba sprawdzania, czy dany obiekt jest jakiegoś typu. Powinniśmy tworzyć nasze obiekty tak, aby taka sytuacja nigdy nie miała miejsca. Jeśli będzie miała, to złamaliśmy zasadę podstawienia Liskov.

Ogólnie jest to bardzo ważna zasada, jednakże w praktyce rzadko kiedy powinna istnieć potrzeba jej zastosowania. Wszystko dlatego, że odnosi się ona do dziedziczenia, a my dziedziczenia powinniśmy unikać. W zamian powinniśmy korzystać z kompozycji. W związku z tym, jeśli w naszym kodzie chcemy skorzystać z dziedziczenia, zastanówmy się najpierw czy jest nam ono niezbędne. Czy przypadkiem nie lepiej będzie skorzystać z kompozycji? Jeśli jednak dziedziczenie to jedyne wyjście, wtedy pamiętajmy o tej zasadzie.

Zasada segregacji interfejsów

Wiele dedykowanych interfejsów jest lepsze niż jeden ogólny.

Zasada bardzo łatwa w stosowaniu, a mimo to wciąż wielu z nas dodając nową metodę do interfejsu, po prostu ją dodaje. Nie zastanawiamy się, czy dana metoda do tego interfejsu pasuje, czy przypadkiem nie powinniśmy stworzyć nowego interfejsu albo rozbić istniejący na kilka mniejszych.

Moim zdaniem tutaj najbardziej może nam pomóc, gdy przestaniemy myśleć tylko o tym, aby tworzyć małe interfejsy, a zaczniemy tworzyć małe klasy. Małe klasy nie są w stanie implementować dużych interfejsów, więc po części ta zasada będzie spełniana automatycznie. Jeśli chodzi o utrzymanie i rozwój projektu, małe klasy są dużo ważniejsze niż małe interfejsy.

Zasada odwracania zależności

Wysokopoziomowe moduły nie powinny zależeć od modułów niskopoziomowych. Zależności między nimi powinny wynikać z abstrakcji.

Myślę, że obecnie już nikt nie ma problemów z odwróceniem zależności. Chyba w każdym projekcie są używane wzorce wstrzykiwania zależności. Czasem może tych abstrakcji tworzymy nawet za dużo, ale moim zdaniem w tym przypadku lepiej jak jest ich za dużo, niż za mało. Najważniejsze, aby nasze klasy i projekty były łatwe w testowaniu – jeśli ceną za to jest duża ilość abstrakcji, to jestem ją skłonny ponieść. Jeszcze nigdy nie zabolało mnie to, że miałem zdefiniowanych za dużo interfejsów. Z drugiej strony zdarzało mi się mieć problemy w napisaniu testów do jakiejś klasy, ponieważ tych abstrakcji brakowało. Jednak nie należy przesadzać – interfejsy dla klas z Modelu są w większości przypadków zupełnie niepotrzebne.

Jest jednak jedna rzecz, nad którą powinniśmy się tutaj szczególnie pochylić – chodzi o ponowne używanie wcześniej zdefiniowanych już abstrakcji. Czasem lepiej jest stworzyć nowy interfejs czy klasę, niż potem męczyć się z tym, że dana metoda jest używana gdzieś indziej i nie możemy jej zmienić albo kod po tych zmianach jest trudny do rozszyfrowania.

Słowo na koniec

Zasady SOLID nie są lekarstwem na wszystko. Nie trzeba w nie ślepo wierzyć i bezgranicznie dążyć do tego, aby były zawsze i wszędzie spełnione. One mają nam pomóc w stworzeniu dobrze zaprojektowanego systemu, który w przyszłości będzie łatwy w utrzymaniu.

Tagi:

3 myśli na “SOLID – pragmatycznie”

  1. Pingback: dotnetomaniak.pl

  2. Wydaje mi się, że „Zasadę segregacji interfejsów” aplikujemy również w przypadku kiedy nasza klasa lub metoda przyjmuje np. listy tylko do odczytu (IReadOnlyList) i dzięki temu „klient” od razu wie, że może założyć (lub zaufać), że nic z listy nie zniknie i nic nowego się nie pojawi.

    Co do „Zasady podstawienia Liskov” to spotkałem się też z interpretacją w której interfejsy też są brane pod uwagę. Na przykład załóżmy, że masz zestaw obiektów generujących raporty (tak kompletnie z czapy wymyślam teraz) np. z jakiejś hurtowni danych (jeden zwraca jedynie dane o pracownikach, inny o towarach itp.). Każdy z tych raportów zwracany jest w postaci JSON w ustalonej schemie tak, że system jest w stanie odczytać te raporty. I nagle ktoś dodaje inny generator implementujący ten sam interfejs, ale zwracający JSON’a w zupełnie innej schemie i zonk, system się wykrzacza, Hmm, ja to uznałbym za złamanie zasady podstawienia Liskov, bo system kompletnie się tego nie spodziewał, ale może to jeszcze inny problem i zupełnie nie ma związku z Liskov ?

    1. Zgadza się – ograniczanie tego, co klient może zrobić poprzez przekazanie „mniejszego” interfejsu jak najbardziej pasuje do zasad segregacji interfejsów i jest bardzo dobrym pomysłem.

      Jeśli chodzi o zasadzę podstawienia Liskov, to opisywany przez Ciebie przykład może nie pasuje do niej książkowo, jednak moim zdaniem to też kwalifikuje się do złamania tej zasady. System oczekuje, że Json będzie spełniał odpowiedni kontrakt, a on został złamany i przez to metoda do deserializacji nie działa poprawnie. Tutaj jednak takiej sytuacji można uniknąć poprzez wzbogacenie interfejsu o metodę deserializującą, aczkolwiek to też nie zawsze bywa pożądane.

Dodaj komentarz