Zasady SOLID na przykładach w Ruby
By tworzyć elastyczny, trwały i wszechstronny kod.
Trochę motywacyjnej gadki
Z pewnością zetknąłeś się już z kodem, który “działa, ale lepiej go nie ruszać”. Jest to jeden ze znaków, że kod został od początku źle zaprojektowany lub potem coś poszło nie tak.
Przez dziesiątki lat, wielu błyskotliwych programistów starało się jak najzwięźlej uchwycić zasady/reguły/wytyczne, których znajomość pozwala tworzyć porządny kod: elastyczny, trwały i wszechstronny. Wielu za ich podstawę uznaje właśnie zasady SOLID. Warto je dobrze zrozumieć.
Również z tego względu, że logarytmicznie przybywa nas — programistów, średnio co 5 lat populacja programistów podwaja się. Innymi słowy połowa nas, w 2018 roku dokładnie 57%, ma mniej niż 5 lat doświadczenia.
Dlatego jeśli już coś robimy, na prawdę warto robić to z dużą świadomością i najlepiej jak potrafimy bo za kilka lat z naszym kodem może mieć do czynienia bardzo wielu nowych programistów.
Zły kod
Aby leczyć trzeba znać symptomy choroby. Spójrzmy szybko na oznaki, że z kodem dzieje się nie najlepiej. Dzięki temu szybciej będziemy potrafili reagować — stosując SOLID i być może ratując sytuację.
- ciężko coś zmienić, bo najdrobniejsza zmiana ma wpływ na wiele innych elementów systemu/aplikacji — “zesztywnienie” (rigidity wg R. Martin)
- nasza zmiana powoduje błąd/pad/rozwałkę w innych, nieoczekiwanych, niepowiązanych częściach systemu — ”kruchość” (fragility)
- ciężko jest wykorzystać kod gdzie indziej, jest silnie związany z obecnym systemem — “zastanie” (immobility)
A teraz jak to powinno być zrobione
Klasa powinna mieć tylko jeden powód zmian
Dlaczego? Dlatego, że w innym razie (więcej niż 1 powód) narażamy się na wszystkie trzy powyższe zagrożenia dot. złego kodu. Sprawdźmy…
Powyższa klasa może mieć dwa powody (wektory) zmian:
- ktoś (A) zechce zmienić formatowanie emaili => metoda
email_invoice
- ktoś (B) zechce zmienić format wyświetlania => metoda
short_name
Jak widać obie metody bazują na name
a więc w obu przypadkach konieczna będzie jej modyfikacja. W tym momencie (A) może wprowadzić zmiany, które wpłyną na nieoczekiwane rezultaty dla (B). Wraz ze wzrostem liczby odniesień w aplikacji do klasyInvoice
rosło będzie rigidity i fragility.
Do takich właśnie powiązań i współzależności dochodzi w klasach odpowiedzialnych za więcej niż jedno zadanie. Dla Seniorów Rails: a teraz spójrzcie na najdłuższy (LOC) Value Object na ActiveRecord’zie w Waszej apce i skonfrontujcie to z powyższą zasadą :)
Nie chodzi tutaj o tworzenie klas posiadających wyłącznie jedną metodę. Metod może być wiele, jednak tylko jeden powód zmian.
Jednym z wielu sposobów rozbicia powyższej klasy na dwie z zachowaniem SRP może być poniższa propozycja. Metoda name
została wydzielona do bazowej klasy Invoice
i może być teraz w razie konieczności niezależnie (bezpiecznie) nadpisana w klasach dziedziczących:
Jedna bardzo ważna rzecz dotycząca wszystkich zasad SOLID. Nic za darmo. Rozbijanie przeładowanych klas ma swoje plusy ale równocześnie wprowadza rozproszenie logiki (rośnie liczba klas w aplikacji). Dlatego zasada brzmi “Klasa/moduł powinien mieć tylko jeden powód zmian”.
Tu dotykamy kwestii zarządzania zależnościami (Dependency management wg R. Martina) czyli rozsądkowego zachowania balansu pomiędzy dwoma powyższymi zależnościami. Temat na osobny artykuł. W skrócie. To my musimy przewidzieć, które klasy będą szeroko używane, a które będą się zmieniać rzadko lub wcale. Nie jest to proste i oczywiste ale sama świadomość to już dużo.
Można rozszerzyć klasę bez modyfikowania jej
Dlaczego? Każda modyfikacja klasy to ryzyko wprowadzenia błędu oraz ewentualnie konieczność zmodyfikowania wszystkich użyć klasy w aplikacji. Spójrzmy na kod:
Jeśli ktoś zechce wprowadzić nowy atrybut do print_out
(rozszerzyć klasę) kod klasy będzie musiał ulec zmianie (niechciana modyfikacja):
Jest wiele sposobów zaimplementowania zasady OCP. Najprostszą i bardzo fajną opcją w Ruby jest przekazanie zmiennej logiki przez blok:
A jeśli zmienna logika jest obszerniejsza, można opakować ją w klasy i wstrzykiwać przez parametry.
Główna idea to tak “zabezpieczona” klasa główna, aby jej najistotniejsza logika była nie-do-ruszenia, innymi słowy, wszystko co ktoś mógłby chcieć zmienić, jest możliwe przez wstrzyknięcie.
Możliwość zastąpienia instancji klasy nadrzędnej przez instancję dowolnej klasy podrzędnej
Dlaczego? Ponieważ złamanie tej zasady naraża na złamanie również drugiej zasady — OCP (open/close). Najprostszy przykład:
Aby dostosować kod po wprowadzeniu nowego formatu settings
w AdminUser
musielibyśmy (uprośćmy przykład i załóżmy, że to jedyna możliwość) zmodyfikować active?
w User
. Zatem rozszerzenie (dodanie nowej klasy pochodnej) wymusza zmiany w klasie bazowej — złamanie OCP:
Wniosek jest prosty. Jeśli widzisz podobne konstrukcje to sygnał, że kod uciekł w błędnym kierunku i należy rozwiązać problem przez naprawę klas pochodnych (tu AdminUser
) by współpracowały z klasą bazową:
W necie krąży inny przykład z klasą Rectangle
i Square
. Też warto go znać i zrozumieć:
Szybko okazuje się, że nie zastąpimy instancji Rectangle
instancjąSquare
, bo zmiana wysokości kwadratu zmienia równocześnie jego szerokość. Taki przypadek może spowodować błąd UI, gdyż niespodziewanie obiekt Square
zamieni się w prostokąt:
Próba ratunku:
… również może doprowadzić do nieoczekiwanego zachowania aplikacji bo może nie być intencją programisty wywołującego shapes.each { |shape| shape.set_width(100) }
wywołanie some_ui_height_related_callbacks
.
Jak widać nie zawsze rzeczywiste relacje ze świata przekładają się 1–1 na strukturę obiektów i relacji w aplikacji i ta świadomość to już dużo. W powyższym przykładzie klasa Square
nie powinna dziedziczyć po Rectangle
, gdyż wprowadza niechciane zależności i utrudnia rozwój aplikacji. Obserwując podobne wyjątki w kodzie konieczne by jakieś obiekty ze sobą współpracowały należy zastanowić się nad wydzieleniem grup podobnych obiektów.
Uważnie udzielaj dostępu do api/klas/metod
Dlaczego?
Po pierwsze, dość oczywiste. Gdy klient/klasa/moduł otrzymuje więcej niż potrzebuje to bezpośrednio lub w przyszłości w nieoczekiwanych okolicznościach może dojść do niechcianego i nieprzewidywalnego zdarzenia.
Wydzielać niezbędne zestawy metod (ograniczać) można np. przez delegowanie wybranych metod:
Po drugie. By uniezależnić się od eskalacji zmian w interfejsie. Gdy jeden z “klientów” naszego interfejsu (użytkownik api/moduł/klasa) zapragnie zmian lub odwrotnie — gdy my zapragniemy zmienić zachowanie interfejsu dla określonego klienta/grupy klientów. Im dokładniejsza segregacja klientów interfejsu tym łatwiej będzie to zrealizować bez konieczności eskalowania zmian na pozostałych, niezainteresowanych klientów. Przykład:
Oczywistym jest, że każda zmiana logiki Api.recent_orders
dotknie wszystkich trzech klientów. Jeśli np. wśród naszych klientów znajdzie się jeden pomysłowy, np. client3
, i zapragnie zmian to powstanie problem. Będziemy mieli dwie opcje. Złą i gorszą (kolejność przypadkowa):
- stworzyć dla niego odrębną metodę (i “przekonać” do użycia),
- stworzyć dla pozostałych dwóch klientów odrębną metodę i ich przekonać do użycia.
Jak widać oba rozwiązania nacechowane są nieelegancją :) Uprzednia, rozważna segregacja może nas w podobnej sytuacji ochronić.
Sposobów segregacji jest sporo, można zastosować np. wzorzec proxy i zawsze wychodzić elegancko z każdej sytuacji:
Najważniejsze aby mieć świadomość, że wraz ze wzrostem liczby klientów naszego interfejsu, czymkolwiek on jest, choćby klasą z jedną metodą, rośnie ryzyko zmian, które docierać będą do wszystkich pozostałych klientów. Dlatego każdego klienta, który może być powodem zmian warto dopuścić do użycia przez warstwę pośrednią lub stosunkowo szybko zareagować i ją stworzyć.
Odwrócenie kierunku zależności: obiekty niższego poziomu powinny zależeć od obiektów wyższego poziomu.
Dlaczego? Ponieważ jeśli jest odwrotnie (wyższe zależą od niższych) to zmiany szczegółów wymuszają zmiany w wyższych warstwach aplikacji czego bardzo nie chcemy bo zależy nam aby jądro biznesowej logiki było niezależne.
Ta zasada akurat dokładnie odzwierciedla rzeczywistą hierarchię w firmach. Szef wykonuje zadania na wyższym poziomie abstrakcji: kogo/ilu zatrudnić, na jaki rynek wkroczyć, itp. Nie powinien zajmować się szczegółami: jaką trasą pojedzie dostawca, jakiego kleju użyć, itp.
Decyzje szefa przekładają się na zmiany w niższych poziomach hierarchii. Zmiany w niższych poziomach, np. inna trasa dostawcy, inny klej, nie powodują (najczęściej) zmian funkcjonowania firmy na wyższych poziomach.
Prosty przykład:
Jeśli zmieni się działanie metod *HDD (np. inne znaki końca wiersza, które trzeba wcześniej przetworzyć) lub firma przejdzie na ssd (szczegóły niższego poziomu), konieczna będzie zmiana w obiektach wyższego poziomu:
Stosując technikę wstrzyknięcia zależności można odwrócić zależność:
… i spowodować, że zmiany szczegółów zajdą jedynie na niższym poziomie bez wpływu na wyższy poziom:
Podsumowanie
Mam nadzieję, że użyłem trafnych przykładów, pomogłem zrozumieć zasady SOLID i dowiodłem, że posiadając tą wiedzę elastyczny, trwały i wszechstronny kod jest w naszym zasięgu.
Sugestie, opinie jakikolwiek feedback mile widziany. Zachęcam do poszerzania wiedzy — źródła poniżej. Dziękuję za czas i pozdrawiam! Mateusz.
Źródła:
- Robert C. Martin (Uncle Bob) — The Principles of ODD
- Robert C. Martin (Uncle Bob) — SOLID Principles of OO and Agile Design
- Jordan Hudgens — edutechional
Jeżeli zainteresował Cię artykuł, zapraszam do kontaktu z nami: praca@akra.net | www.akra.net | www.facebook.com/akra.net