Mateusz Leszczyński
AKRA POLSKA
Mateusz LeszczyńskiSenior Ruby on Rails Developer @ AKRA POLSKA

SOLID – dobre praktyki programowania na przykładzie Ruby

Przekonaj się na konkretnych przykładach, jak tworzyć elastyczny, trwały i wszechstronny kod w Ruby za pomocą dobrych praktyk zebranych przez samego Wujka Boba.
14.11.20188 min
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:

<p>Loading...</p>