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 ją 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 są 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
Prześlij komentarz