SOLID – dobre praktyki programowania na przykładzie Ruby
Nieco o motywacji na początek
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 pięć lat populacja programistów podwaja się. Innymi słowy, ponad połowa z nas (a dokładnie 57%) w 2018 roku ma mniej niż pięć lat doświadczenia. Dlatego, jeśli już coś robimy, naprawdę 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.
Źródło: StackOverflow Developer Survey Results 2018
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 o tym, jak to powinno być zrobione
Klasa powinna mieć tylko jeden powód zmian.
Dlaczego? Dlatego, że w innym razie (więcej niż jeden powód) narażamy się na wszystkie trzy powyższe zagrożenia dot. złego kodu. Sprawdźmy.
class Invoice
attr_accessor :product_name
def email_invoice
puts "Emailing an invoice..."
puts name
end
def short_name
puts name
end
def name
"Invoice for #{@product_name}"
end
end
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 klasy Invoice
będzie rosło 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 ActiveRecordzie 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:
class Invoice
attr_accessor :product_name
# default name
def name
"Invoice for #{@product_name}"
end
end
class InvoiceEmail < Invoice
def send
puts "Emailing an invoice..."
puts name
end
def name
"Any other, specific formatting of a name #{@product_name}"
end
end
class InvoicePresenter < Invoice
def short_name
puts name
end
end
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:
class OrderReport
attr_accessor :attributes
def initialize(various_attributes)
@attributes = various_attributes
end
def print_out
puts "Summary title"
puts @attributes[:item_name]
end
end
report = OrderReport.new(item_name: "Awesome ebike")
report.print_out
# Summary title
# Awesome ebike
Jeśli ktoś zechce wprowadzić nowy atrybut do print_out
(rozszerzyć klasę), kod klasy będzie musiał ulec zmianie (niechciana modyfikacja):
class OrderReport
attr_accessor :attributes
def initialize(various_attributes)
@attributes = various_attributes
end
def print_out
puts "Summary title"
puts @attributes[:item_name]
puts @attributes[:total_cost]
end
end
report = OrderReport.new(item_name: "Awesome ebike", total_cost: 2000.0)
report.print_out
# Summary title
# Awesome ebike
# 2000.0
Jest wiele sposobów zaimplementowania zasady OCP. Najprostszą i bardzo fajną opcją w Ruby jest przekazanie zmiennej logiki przez blok:
class OrderReport
attr_accessor :attributes
def initialize(various_attributes)
@attributes = various_attributes
end
def print_out
puts "Summary title"
yield @attributes
end
end
report = OrderReport.new(item_name: "Awesome ebike", total_cost: 2000.0)
report.print_out do |attrs|
puts attrs[:item_name]
end
# Summary title
# Awesome ebike
report.print_out do |attrs|
puts attrs[:item_name]
puts attrs[:total_cost]
end
# Summary title
# Awesome ebike
# 2000.0
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:
class User
attr_accessor :settings
def active?
@settings[:status] == :active
end
end
class AdminUser < User
end
basic_user = User.new
basic_user.settings = { level: 0xFACE, status: :active }
basic_user.active?
# true
admin_user = AdminUser.new
admin_user.settings = [ 0xCAFE, :active ]
admin_user.active?
# TypeError (no implicit conversion of Symbol into Integer)
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:
class User
def active?
if self.class == User
@settings[:status] == :active
elsif self.class == AdminUser
@settings[1] == :active
end
end
end
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ą:
class AdminUser
def initialize(settings_array)
settings_from_array(settings_array)
end
# ew. dodatkowo
def settings=(settings_array)
settings_from_array(settings_array)
end
def settings_from_array(settings_array)
@settings[:level] = settings_array[0]
@settings[:status] = settings_array[1]
end
end
W necie krąży inny przykład z klasą Rectangle
i Square
. Również warto go znać i zrozumieć:
class Rectangle
attr_accessor: :width, :height
def set_width(width)
@width = width
some_ui_width_related_callbacks
end
def set_height(height)
@height = height
some_ui_height_related_callbacks
end
end
class Square < Rectangle
end
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:
shapes = [ Rectangle.new, Square.new ]
shapes.each { |shape| shape.set_width(100) }
Próba ratunku:
class Square < Rectangle
def set_width(width)
@width = width
@height = height
some_ui_width_related_callbacks
some_ui_height_related_callbacks
end
def set_height(height)
set_width(height)
end
end
…również może doprowadzić do nieoczekiwanego zachowania aplikacji, bo intencją programisty wywołującego shapes.each { |shape| shape.set_width(100) }
może nie być 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 - 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.
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.
class BlogActions
def create_post; end
def edit_post; end
def delete_post; end
end
class Moderator < BlogActions
end
moderator = Moderator.new
# not wanted!
moderator.delete_post
Wydzielać niezbędne zestawy metod (ograniczać) można np. przez delegowanie wybranych metod:
class Moderator
extend Forwardable
def_delegators :@blog_actions :edit_post
def initialize(blog_actions)
@blog_actions = blog_actions
end
end
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:
class Api
def self.recent_orders
Order.recent
end
end
client1 = Api.recent_orders
client2 = Api.recent_orders
client3 = Api.recent_orders
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:
class Api
def self.recent_orders
Order.recent
end
end
class IngeniousClientsApi
def self.recent_orders
Api.recent_orders.take(10)
end
end
client1 = Api.recent_orders
client2 = Api.recent_orders
client3 = IngeniousClientsApi.recent_orders
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:
# higher level
class Copy
def self.call
WriteToHDD.call(ReadFromHDD.call)
end
end
# lower level
Copy.call
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:
# higher level
class Copy
def self.call
WriteToSSD.call(ReadFromSSD.call)
end
end
# lower level
Copy.call
Stosując technikę wstrzyknięcia zależności można odwrócić zależność:
# higher level
class Copy
def self.call(reader, writer)
writer.call(reader.call)
end
end
# lower level
Copy.call(WriteToHDD, ReadFromHDD)
…i spowodować, że zmiany szczegółów zajdą jedynie na niższym poziomie bez wpływu na wyższy poziom:
# higher level
class Copy
def self.call(reader, writer)
writer.call(reader.call)
end
end
# lower level
Copy.call(WriteToSSD, ReadFromSSD)
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. Zachęcam do poszerzania wiedzy - źródła poniżej.
Odwiedź mój LinkedIn.
Ź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