Mobile

Kompozycja jest lepsza od dziedziczenia. Dlaczego?

Intuicyjnie wiemy czym kompozycja jest. Patrzymy na obraz i widzimy jak osobne “fragmenty” są połączone ze sobą w taki sposób, że tworzą “jedną” całość. Słuchamy muzyki i słyszymy jak harmonia, melodia i rytm tworzą jeden utwór. Czytamy dobre książki i wchodzimy w ten “świat” z jego zasadami, które są dla nas “spójne”.

Łukasz Stocki. iOS Developer w Veepee. Swoją przygodę z platformą Apple zaczynał wraz z iOS5. Jest jednym z administratorów grup Facebook-owych dla iOS Developerów oraz z ogłoszeniami o pracę. Współtworzy Lekko Technologiczny kanał na YouTube, na którym, razem z przyjaciółmi, bez nadęcia starają się przybliżyć różne pomysły z programowania.


Co to jest dziedziczenie?

Jestem pewien, że skoro tu jesteś, to wiesz czym jest dziedziczenie. Jednak abyśmy wiedzieli razem o czym mówimy to na potrzeby tych luźnych rozważań powiem, że dziedziczenie to mechanizm re-użycia kodu.

Polega on na tym, że definiujemy klasy (definicje obiektów), które mają pewne zachowania/właściwości. Klasy mogą dziedziczyć po innych klasach (lub tylko po jednej) i wtedy poza “swoimi” definicjami dziedziczą zachowania klasy “rodzica”. Przykładów takich hierarchii dziedziczenia w życiu mamy mnóstwo. Jednym z koronnych przykładów może być klasyfikacja zwierząt.

W naszym ukochanym UIKit-cie też znajdziemy coś podobnego, wystarczy wejść na stronę Cocoa Fundamentals Guide i namierzyć obrazek dla Overview of UIKit Classes (swoją drogą sekcja A Bit of History też jest ciekawa).

Źródło: developer.apple.com

Jasno widać, że UIButton dziedziczy po UIControl, ten dziedziczy po UIView itd. aż do NSObject. Jest to bardzo rozbudowana struktura. Jeżeli trzeba dołożyć coś nowego, to gdzie właściwie najlepiej to umieścić? Może być z tym mały kłopot.

Kolejna sprawa jest taka, że na samej górze takiej hierarchii powinny być zachowania wspólne dla wszystkich klas niżej.

Życie jednak bardzo szybko weryfikuje czy coś takiego jest możliwe do utrzymania. Czy naprawdę każdy komponent musi wiedzieć o tym czy użytkownik jest zalogowany? Co ma w koszyku? Czy jest “tapalny”? A co jak wymagania są takie, że musi mieć oba zachowania, ale nie ma wspólnego rodzica?

No niestety. System działa, mamy wielu klientów, którzy już konsumują to API i nie możemy od tak po prostu ich zostawić (nasze kredyty się same nie spłacą). Może i ta hierarchia była w porządku na moment, kiedy była tworzona. Jednak jest to za mało elastyczne rozwiązanie. Musimy mieć coś, co pozwoli nam luźniej wiązać ze sobą te elementy.

Jak jest ciemno to odpalamy agregat

Zostawiając już malarstwo, muzykę i literaturę za nami, to w “naszym świecie” interesuje nas kompozycja związana z programowaniem. I tu zaryzykuję stwierdzenie najbardziej popularną formą kompozycji jest agregacja.

Polega ona na tym, że tworzymy większe elementy agregując w nich mniejsze:

struct User {
    let name: String
    let age: Int
}

W tym trywialnym przykładzie dwa niezależne typy (String, Int) zostały zagregowane do jednego typu “User”. Ten typ z kolei mogę po raz kolejny zagregować w innym typie:

struct ContactInfo {
    let user: User
    let address: String
}

Dokładnie taką sama kompozycję osiągamy w momencie, gdy tworzymy widoki. Jeden widok może zawierać inne widoki, ten nowy może być umieszczony w jeszcze innym. Zabawie nie ma końca.

To jednak jest tylko agregacja danych. Jeżeli dodam do tych danych jakieś zachowanie to powoli wracam do punktu wyjścia. Czyli gdy potrzebuję troszeczkę innego zachowania to “powinienem dziedziczyć” po klasie, której zachowanie chcę zmienić i w podklasie to zachowanie nadpisać (ewentualnie wołając oryginalną implementację przez “super”). Alternatywą do tego rozwiązania może być wzorzec strategii, gdzie zachowanie jest wstrzykiwane… ale, co gdy chciałbym to zachowanie zmienić tylko troszeczkę? Powinienem dziedziczyć po strategii? Wygląda jak schowanie czegoś pod dywan a nie rozwiązanie.

Jak sami widzicie pokusa do dziedziczenia jest bardzo duża. Jak mawia mój znajomy: “Tak się nie godzi!”.

Zainspirujemy się tu językami funkcyjnymi jak np. Haskell i rozdzielimy zachowanie od danych. Czyli będziemy dążyć do sytuacji, gdzie mamy bardzo głupie dane (bez zachowań) oraz bardzo mądre funkcje (robiące coś z tymi danymi).

Kompozycja funkcji

Na dzień dobry Swift jest naszym sprzymierzeńcem w tym podejściu. Dlaczego? Ponieważ “po funkcji” nie można dziedziczyć. Więc na pewno nie wpadniemy w pułapkę, z której chcemy uciec.

Kolejna rzecz to będziemy starać się robić jak najprostsze funkcje. Robiące tylko jedną rzecz. Nie zmieniające świata zewnętrznego, zwracające na to samo wejście to samo wyjście. Korzystając ze słownika programowania funkcyjnego, będziemy pisać “czyste funkcje” (ang. pure functions). Następnie z tych prostych kawałków będziemy budować większe kawałki, które też będziemy mogli używać w innych miejscach.

Pierwsze kroki w kompozycji

Potrzebujemy czegoś, co będziemy mogli ze sobą komponować:

func  incr(_ x: Int) -> Int { x + 1 }
func multi(_ x: Int) -> Int { x * x }

Mając to, co mamy zaczniemy z niskiego ka:

// x + 1
let x = 1
incr(x) // 2

Więc chcę wyrazić równanie “x + 1” w kodzie używając tych klocków jakie posiadam teraz. Potrzebuje “x” więc go definiuję (można by się obejść bez tego, ale dzięki temu przykłady mapują się troszeczkę lepiej). Przekazuję x do funkcji i mam wyrażone równanie.

Rzućmy okiem na kilka innych przykładów (wiem, że są nudne, ale uwierz mi idzie to w dobrą stronę):

// x * x
let x = 2
multi(x) // 4

Jak na razie nie ma nic nadzwyczajnego. Dokładnie to samo robimy w pracy każdego dnia. Zobaczmy jednak, co się zacznie dziać, gdy równania nieco się nam skomplikują:

// (x^2) + 1
let x = 2
incr(multi(2)) // 5

// ((x + 1)^2)^2 + 1
let x = 0
incr(multi(multi(incr(0)))) // 2

Ostatni przykład jeszcze jest do ogarnięcia, ale czytelność tego rozwiązania też przestaje nas zadowalać. A mamy tu do czynienia z prostym równaniem matematycznym, a nie logiką biznesową, gdzie nazwy tych funkcji/metod mogą być mniej intuicyjne.

Możemy na ten problem spojrzeć nieco z innej strony. Jest tutaj coś co się powtarza. A jeżeli coś się powtarza to znaczy, że możemy to zamknąc w abstrakcji i to nazwać! Używając bardziej buzzword-owego terminu, mamy tutaj do czynienia z wzorcem projektowym.

Co tutaj jest tym wzorcem? Proste, wywołanie jednej funkcji i użycie jej wyjścia jako wejścia do kolejnej funkcji.

func compose<A,B,C>(
    _ f: @escaping (A) -> B,
    _ g: @escaping (B) -> C
    ) -> (A) -> C {
    return { a in
        let b = f(a)
        let c = g(b)
        return c
    }
}

I dokładnie to się dzieje w funkcji “compose”. Można to zapisać “gęściej”, ale w tym wypadku zależy mi na tym, aby ten wzorzec był widoczny w kodzie.

Zwraca ona funkcję/blok, który przyjmuje argument do “włożenia” do pierwszej funkcji “f”. Wynik działania tej funkcji jest zapisany w stałej b (i jest też typu B ponieważ funkcja compose jest generyczna). W następnym kroku “b” jest argumentem do funkcji “g”, która zwraca wartość “c” i ta na końcu jest zwracana z tej funkcji.

Mając nową zabawkę “skomponujemy” funkcję: (x + 2)^4 + 3. Nie mamy wszystkich klocków jakich potrzebujemy. Jednak przy pomocy tej funkcji nie będziemy musieli aż tak dużo ich dodawać. Wystarczy jeden a reszta wyniknie z kompozycji.

Niech stanie się zero, niby nic a jednak coś:

let zero = 0

Od teraz mam “coś”/abstrakcję, która wyraża pomysł zera. Wszędzie w kodzie, gdzie potrzebuję “zera”, używam teraz tej stałej zamiast “wartości z powietrza”. Jak mówiłem, niby nic a jednak coś.

Zaletą kompozycji jest to, że mogę tworzyć nowe rzeczy ze starych i tą nową rzecz też mogę komponować. Może to wyglądać np. tak:

let addTwo   = compose(incr, incr)   // +2
let addThree = compose(addTwo, incr) // +3
let tetra    = compose(multi, multi) // ^4

let addTwoAndTetra = compose(addTwo, tetra)   // (x + 2)^4
let final = compose(addTwoAndTetra, addThree) // (x + 2)^4 + 3

final(-1)   // 4
final(zero) // 19
final(1)    // 84

Wszystkie kawałki zostały stworzone z tych obecnie dostępnych. Jest to dowód na re-użycie kodu. Co więcej nowe fragmenty / stałe / symbole posłużyły do zbudowania jeszcze większej całości.

Większą całość możemy zdekomponować na mniejsze elementy, aby o nich “rozumować” i potem ponownie złożyć w jedną całość. Czy takie kawałki trzeba testować jednostkowo? Przecież mamy testy na funkcje “incr” i “multi”. A ponieważ re-używamy ten kod to czy “ślepe” pisanie testu jednostkowego tylko nie zwiększa ilości linijek kodu do utrzymania bez dodawania jakiejś wartości? Jest się nad czym zastanowić.

Ponieważ nasze funkcje są czyste (nie mają żadnych efektów ubocznych) to możemy wstawić wywołania funkcji compose w miejsce stałych, jakie zostały zdefiniowane i nie powinno to mieć wpływu na wynik końcowy działania programu (osobiście chciałbym, aby każdy mój kod miał taką właściwość szczególnie przy refaktorze).

// (x + 2)^4 + 3
let composed =
compose(           // <-- plumbing  (x + 2)^4 + 3
    compose(       // <-- plumbing  (x + 2)^4
        compose(   // <-- plumbing   x + 2
            incr,  // <-- basic block   +1
            incr   // <-- basic block   +1
        ),
        compose(   // <-- plumbing  ^4
            multi, // <-- basic block   ^2
            multi  // <-- basic block   ^2
        )
    ),
    compose(      // <-- plumbing  +3
        compose(  // <-- plumbing  +2
            incr, // <-- basic block   +1
            incr  // <-- basic block   +1
        ),
        incr      // <-- basic block   +1
    )
)

composed(-1)   == final(-1)   // true
composed(zero) == final(zero) // true
composed(1)    == final(1)    // true

W tym przykładzie jasno widać co jest hydrauliką potrzebną do przepchania wartości z jednego miejsca do drugiego. Widać też podstawowe klocki , których używamy do tworzenia nowych rzeczy. Nie oszukujmy się, ta hydraulika zawsze gdzieś w aplikacjach, które piszemy jest. Przemieszana z tymi podstawowymi klockami w jeszcze mniej przejrzysty sposób niż kawałek kodu przedstawiony wyżej. Można powiedzieć, że ten kod krzyczy mówiąc, co jest czym.

Pierwszy przykład, gdy mamy mniejsze fragmenty z osobnymi symbolami/stałymi, w moim osobistym odczuciu jest czytelniejszy. W drugim przykładzie za to jasno widzę strukturę i mam swobodę w jej definiowaniu.

Jest jeszcze jedna sztuczka, którą możemy zrobić z funkcją compose w Swift (pewnie w wielu innych językach też). Możemy funkcje zdefiniować adhoc!

// (x + 3)^3 + 10
let composed =
compose(
    compose(
        { (x: Int) in x + 3 },
        { (x: Int) in x * x * x }
    ),
    { (x: Int) in x + 10 }
)

composed(-3)   // 10
composed(zero) // 37

Wymieniliśmy reużycie kodu na gęstość i ekspresyjność. Potrzebne kawałki zostały zdefiniowane w miejscu ich użycia. I jak ten nowo powstały element możemy komponować gdzieś indziej, tak jego poszczególne elementy musimy zaimplementować/skopiować jeszcze raz w innych miejscach. Coś za coś.

Właściwie dlaczego jest lepsza?

Ponieważ pozwala nam luźniej wiązać ze sobą elementy bez tworzenia sztywnych hierarchii. Pozwala na zmianę zdania i reużycie kodu, tylko tego, który chcemy a nie wszystkiego co się przykleiło do “god classy”.

Podsumowanie

Wszystko to, co tu zostało powiedziane jest przenaszalne na inne języki. Uczymy się czegoś dużo bardziej uniwersalnego, czegoś co możemy użyć w przyszłości w zaskakujących miejscach. Całym sercem polecam odpalić wujka googla i poszukać więcej.

Brawo! Udało Ci się dotrzeć do końca i mam nadzieję nie umarłeś/aś z nudów to czytając. Jednak mam dla Ciebie złą(?) wiadomość. To nie koniec to dopiero początek! Temat kompozycji i tego jak zrobić, aby “łatwo” i “tanio” się komponowało dopiero jest przed nami. Tak więc zapnij pasy, bo czeka nas długa podróż!


Linki i spojlery

  • Pointfree.cosuper strona, na której dowiesz się bardzo wielu rzeczy z programowania funkcyjnego w Swift. Jedyny minus to, że większość treści jest płatna, ale warto, bo mają też sporo darmowych wartościowych filmików.
  • Programming with Categoriesteoria Kategorii jest to dział matematyki, który opisuje kompozycję. Te wykłady starają się pokazać jak się to przekłada na programowanie. Absolutnie nie jest to potrzebne, aby świetnie pisać programy z tymi właściwościami i w tym stylu. Jednak jestem pewien, że na 100% zgłębiając temat samodzielnie trafisz na takie pojęcia jak monada, funktor czy semigrupa. Nie ma się czego bać, matematycy odkryli to pierwsi. Gdyby zrobili to informatycy to by pewnie się nazywało “AndThenable”, “Mapabale” i “Appendable”.
  • Zapraszam też na Lekko Technologiczny kanał, gdzie poruszamy ten temat.
  • Runesbiblioteka z operatorami do różnych często wykonywanych operacji.
  • Overture – biblioteka do ułatwienia sobie pracy przy komponowaniu funkcji.
  • F# for fun and profitna tej stronie znajdziecie bardzo ciekawe filmy o kompozycji w praktyce. Do tego masa wpisów o pragmatycznym podejściu do funkcyjnego programowania. Oraz jako taka wisienka na torcie, można całą stronę pobrać jako ebook do czytania.

Zdjęcie główne artykułu pochodzi z unsplash.com.

Poniżej znajdziecie oferty pracy z Just Join IT.

Wraz z Tomaszem Gańskim jestem współtwórcą justjoin.it - największego job boardu dla polskiej branży IT. Portal daje tym samym największy wybór spośród branżowych stron na polskim rynku. Rozwijamy go organicznie, serdecznie zapraszam tam również i Ciebie :)

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://geek.justjoin.it/kompozycja-czy-dziedziczenie/" order_type="social" width="100%" count_of_comments="8" ]