SOLID


Dlaczego rozmawiamy o solidzie? Czy wiedza którą mamy nie powinna nam wystarczyć? Teoretycznie w szkołach uczą nas jak programować, uczą podstaw języka, wszystkich paradygmatów języka i paradygmatów programowania obiektowego. To nie wystarcza, pomijane są tematy dobrych praktyk, wzorców i metodyk wytwarzania i utrzymania oprogramowania. Nawet jeśli mamy do czynienia z wiedzą teoretyczną o wzorcach projektowych tudzież architektonicznych to za mało jest przykładów z życia wziętych które mogą pomóc w przyszłości dobrze używać tych wzorców. Łatwo jest użyć wzorców do małych programów typu uniwersyteckiego, natomiast nie jest już tak prostą sprawą użycie ich w istniejących często bardzo dużych systemach, dodatkowo istniejące systemy zazwyczaj są pisane od wielu lat i nie zawsze są dobrze dostosowane do stosowania wzorców.



Każdy kod który został napisany jest już Legacy Code (nawet napisalam go ja 15 minut temu).

Aby dobrze posługiwać się wzorcami trzeba je dobrze poznać.
Popularnym zestawem wzorców projektowych i dobrych praktyk programowania jest SOLID który pokrywa większą część ważnych pytań stawianych przed oprogramowaniem takich jak:
Czy nasz program rozwiązuje problem 
Czy nasz kod jest czytelny dla człowieka
Czy nasz kod jest łatwo utrzymywalny
Czy nasz kod jest łatwo rozszerzalny w razie potrzeby 
Czym granularność naszego kodu jest wystarczająca czy też może powinien być rozbity na mniejsze fragmenty i robić mniej rzeczy na raz.

SOLID to zasady zebrane i spisane, choć nie stworzone przez Roberta Martina zwanego wujkiem Bobem.
Wujek Bob jest bardzo charyzmatyczną i marketingową postacią przedstawiającą dobre praktyki programowania. Wart jest śledzenia. Jednak warto też nałożyć filtr ponieważ nie wszystko jest możliwe do zaimplementowania. A moim zdaniem najlepiej zastosować zasadę złotego środka czysty kod jest dobry jeśli nie przesadzimy z jego puryzmem.


S - Single responsibility principle
Zasada pojedynczej odpowiedzialności. Kasa nie powinna mieć więcej niż jeden powód do modyfikacji.
Kod powinien robić jedną rzecz, tylko jedną rzecz i robicie dobrze. Jeśli kod robi jedną rzecz jest łatwiej zrozumiały, jest również mniej prawdopodobne że będzie musiał być modyfikowany. A jeśli zostanie zmodyfikowany efekty uboczne modyfikacji będą mniej dotkliwe w sensie powstanie mniej błędów.
Jednak nie zawsze jest wiadome i jasne czym jest jedna rzecz którą powinien robić dany kawałek kodu (mówimy zarówno o poziomie projektu, modułu, klasy jak i metody).
Klasycznym przykładem mieszania odpowiedzialności jest klasa użytkownik na której dostępne są operację zarówno "Wylicz podwyżkę" czyli jakieś działanie logiczne, jak i "Zmień dane osobowe" w sensie zapisu danych do bazy. Tutaj oczywiście mamy dwie różne odpowiedzialności, które potrafiliśmy łatwo zidentyfikować i nazwać.
Na bazie jednych pomysłów rodzą się kolejne. Patrząc na zapis do bazy: aktualizacja danych, dodanie nowych danych i usuwanie danych są w miarę spójną całość i będą się zmieniać w większości przypadków razem. Natomiast odczyt danych z bazy danych i jest również zamkniętą całością która może mieć wiele wariantów na przykład pobranie danych posortowanych , bez sortowania, różne zakresy danych potrzebne dla różnych końcówek wyświetlających te dane. Tutaj ta teoretycznie jedna odpowiedzialności jaką jest operowanie danymi przechowywanymi podzielona jest na dwie niezależne od siebie części czyli dostęp typu odczyt oraz dostęp typu zmiana. Cała ta idea leży u podstaw CQRS.



O - Open closed principle
Elementy systemu powinny być otwarte na rozszerzanie ale zamknięte na modyfikację.
Samo to zdanie jest tak skomplikowane. No bo jak to powinniśmy napisać kod raz i już nigdy do niego nie wrócić? Nie powinniśmy go modyfikować za to możemy go rozszerzać?
Za każdym razem jak modyfikujemy kod nieumyślnie możemy wprowadzić do niego błędy i nie mówię tylko o pojedynczej klasie metodzie ale również o całym systemie. Pomyślimy jak wygląda WordPress. WordPress jest jeden z największych dostarczyciel systemu do blogowania i prowadzenia stron internetowych na świecie. Zmiana w samym systemie może spowodować że połowa internetu przestanie się uruchamiać! Wordpress ma wspaniałe rozwinięty system wtyczek. Czego nie da się osiągnać  w podstawowej wersji systemu możliwe jest do osiągnięcia za pomocą odpowiednich wtyczki. Wtyczki są dużo mniejszymi elementami, niezależnymi od systemu głównego, co więcej system WordPress nie zależy od wtyczek. Użytkownik sam decyduje czego używa i kiedy. Jeśli coś się zepsuje we wtyczce- to tylko "na własne życzenie użytkownika", w każdym razie nie spowoduje to globalnego problemu.
Myśląc trochę mniejszymi kategoriami na przykład klasa systemowa String. Gdybyśmy chcieli zmodyfikować klasę systemową String to nie dość że wpłynęlibyśmy na wszystkie jej użycia w całym naszym systemie to jeszcze prawdopodobnie zepsuli byśmy coś z ze zwykłej nieznajomości kodu który już istnieje i jego zastosowania. Bardzo prostą metodą rozszerzenia takiej klasy jak String jest metoda rozszerzająca Extension metod.

Zasada open/close definiuje wyjątki kiedy kod może być modyfikowany
1 Jeśli kod zawiera błędy to powinniśmy te błędy rozwiązać
2 Jeśli chcemy zrefaktoryzowac kod to możemy, o ile nie przedstawimy w ten sposób nowych błędów. Oczywiście refaktoryzację przedstawiamy tylko dlatego że nasz kod zaczyna łamać zasady SOLID.
3 Trochę kontrowersyjna zasada, ale możemy zmieniać kod jeśli nie przedstawimy w ten sposób nowych błędów a klienci naszego kodu nie odczują zmiany czyli że nie będą musieli dostosowywać się do zmian.


To teraz zakładając że mamy jakiś kod, jakieś funkcjonalności, mamy je podzielone na małe odpowiedzialności, naprawdę małe klasy, małe metody. Nie powinniśmy modyfikować tego kodu.
Tak naprawdę te pierwsze dwie zasady rozwalają nam kod na bardzo małe składowe robiąc totalny burdel który teraz byłoby fajnie uporządkować.


L - Liskov substitution principle.
Zasada mówi że powinniśmy być w stanie zastąpić klasa dowolną subklasa tej klasy bez potrzeby dodatkowej modyfikacji kodu.
Czyli w zasadzie jest to rozszerzenie i obostrzenie zasad dziedziczenia, ponieważ wszędzie tam gdzie możemy użyć klasy bazowej możemy użyć też klasy dziedziczącej. Rozszerzenie polega na tym abyśmy mogli zrobić odwrotnie, wszędzie tam gdzie używamy klasy dziedziczącej możemy użyć też klasy bazowej. Zawęża to znacznie możliwości rozszerzenia samych klas.
Liskov określa kilka dodatkowych zasad
1 Warunki wstępne wymagane przez metodę nie mogą zostać wzmocnione przez podklasa
2 Warunki oczekiwane po zakończeniu działania metody nie mogą zostać osłabione przez podklasa
3 Wszystkie zmienne które nie są zmieniane przez metodę w klasie bazowej nie mogą być również zmieniane przez metodę klasy dziedziczącej. Dodatkowo klasa dziedzicząca nie może wprowadzać nowych typów: na przykład jeśli klasa bazowa wyrzuca ogólny Exception i spodziewamy się i obsługujemy Exception to klasa dziedzicząca musi wyrzucać Exception dokładnie tego samego typu nie może go zawęzić/wyspecjalizować.
Wszystko to robimy ze względu na klientów naszego kodu, aby nie musieli oni modyfikować obsługi kontraktów na które się zobowiązaliśmy.

To jest pierwszy krok do znormalizowanie naszego bajzlu z poprzednich dwóch kroków. Moim zdaniem ciężko jest osiągnąć całą zasady Liskova jeśli opieramy się tylko na dziedziczeniu. Możemy wpaść w pułapkę źle wybranej abstrakcji.
Jeśli mamy kaczkę i będziemy myśleć o niej jako o bazowej klasie możemy dojść do punktu gdzie mamy kaczkę zwierzę i kaczkę zabawkę które różnią się diametralnie. Jeśli wybraliśmy kaczkę jako klasę bazową nie będziemy w stanie dotrzymać wszystkich dotychczasowych zasad ponieważ funkcjonowanie obu kaczek zdecydowanie różne. Teoretycznie jesteśmy w stanie nadal obsłużyć kaczkę zabawkę i wszystkie metody które przypisujemy kaczce zwierzęciu zmieniając implementację.
Na przykład latanie jest możliwe w przypadku kaczki zabawkowe jeśli ktoś nią rzuci. Natomiast jedzenie w przypadku kaczki zabawkowej będzie zwracało nic bądź exception. I tutaj teoria się kończy pownieważ w praktyce w tym wypadku łamiemy zasadę Liskova i klient obsługujący klasę zabawkową musi przygotować się na Exception którego nie było by w przypadku klasy bazowej.

Dużo łatwiej jest jest spełnić wymagania liskowa używając interfejsów a w szczególności dobrze podzielonych interfejsów.


I - Interface segregation principle
Zasada segregacji interfejsów mówi że wiele specyficznych interfejsów jest lepsze niż jeden interfejs "robiący wszystko". Klienci nie powinni być zmuszani to implementacji metod których nie potrzebują.
Dzięki segregacji interfejsów czyli rozbijaniu ich na najmniejszy możliwy składy możemy sterować zachowaniem danego obiektu biorąc za przykład naszą kaczkę możemy wyciągnąć interfejs odpowiedzialny za jedzenie, drugi za latanie, trzeci za materiał i kolor farby. Dlaczego tak? Ponieważ latanie nie będzie miała i kaczka zwierzę i kaczka zabawka. Implementacje będą inne ale obie kaczki mogą przebyć pewną odległość drogą powietrzną. Natomiast jedzenie będzie miała tylko kaczka zwierzę, nie musimy karmić kaczki zabawki. Materiał i kolor farby będzie natomiast miała tylko kaczka zabawka ponieważ kaczka zwierzę nie jest zrobiona z materiału tylko jest żywym zwierzątkiem.
Jeśli więc utkneliśmy w martwym punkcie złego wyboru abstrakcji nadal możemy sobie poradzić używając wystarczającej separacji interfejsów jednak najlepiej byłoby gdybyśmy wygrali odpowiednią abstrakcje i nadal posługiwali się małymi wyspecjalizowanymi interfejsami.

W tym momencie mamy już bardzo małe wyspecjalizowane klasy które posiadają swoje jeszcze mniejsze wyspecjalizowane interfejsy wszystko to może być w wielu wersjach które możemy dowolnie wymieniać. 
Aby wszystko teraz połączyć i nie zepsuć powinniśmy zastosować zasadę odwracania zależności.


D - Dependency inversion principle
Zasada odwróconej zależności mówi że moduły wysokiego poziomu nie powinny być zależne od modułów niskiego poziomu, oba poziomy modułów powinny być zależne od abstrakcj.
Oraz że abstrakcje nie powinny zależeć od detali to detale powinny być zależne od abstrakcji.

Najważniejszym założeniem tej zasady jest używanie abstrakcji w każdej interakcji pomiędzy modułami, klasami. Zawsze powinniśmy polegać tylko na abstrakcji nie na konkretnej implementacji. Najlepszym przykładem jest tutaj samochód nie potrzebujemy wiedzieć jak zbudować silnik ani jaki silnik jest w samochodzie, żeby móc go włączyć ponieważ wszystkie samochody maja interfejs stacyjki który zapewnia start silnika.

W praktyce do realizacji tej zasady używamy mechanizmu Dependency Injection oraz kontenerów, które wyręczają nas w tworzeniu klas implementujących wymagane interfejsy.


Bardzo trudno jest stworzyć system który będzie spełniał wszystkie zasady SOLID. Mimo że te dobre praktyki mają pomagać w tworzeniu niezawodnego, łatwo rozszerzalnego, testowalnego oprogramowania prawda jest taka że stosowanie zasad SOLID wymaga dużego nakładu pracy na którego często nie ma dostępnych wystarczających zasobów.



Komentarze

Popularne posty