Mobile

“Tańsza” kompozycja. Operatory w służbie ekspresyjności

Nie ma czegoś takiego jak darmowy lunch. Programowanie to chyba jedno z takich działań, gdzie widać to najlepiej. Nigdy nie wybieramy “najlepszego” rozwiązania, zawsze dokonujemy wyborów “coś za coś”. I zawsze trzeba za to jakoś zapłacić: szybkość za rozmiar, wydajność za prostotę itd.

Łukasz Stocki. iOS Developer w Veepee. Swoją przygodę z platformą Apple zaczynał wraz z iOS5. 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. Autor artykułu pt. Kompozycja jest lepsza od dziedziczenia. Dlaczego?


Abstrakcyjny wstęp

“Prawdziwe życie” jest za skomplikowane (jeszcze) dla komputerów. Dlatego musimy odzierać rzeczywistość z rzeczy “zbędnych”, tak aby można było ją “zmieścić” w tych krzemowych maszynach.

My pod względem rozumienia “prawdziwego życia” nie jesteśmy dużo lepsi. Tworzymy w głowie modele “jak coś działa”, które pomagają nam funkcjonować w świecie.

Odzieranie rzeczywistości

Jak taki proces może wyglądać? Wyobraźmy sobie sytuację, która się powtarza lub rzecz, która jest powszechnie spotykana, ale zawsze tylko troszeczkę inna. Nie mamy takich zdolności, aby zapamiętać każdy szczegół z każdego dowolnego przedmiotu. Dlatego za każdym razem tworzymy mniej konkretne (bardziej ogólne) pojęcie. Wyrzucamy to, co zbędne i oczywiste, a zostawiamy esencję.

Im więcej doświadczenia z jakimś przedmiotem i zjawiskiem, tym to pojęcie robi się bardziej abstrakcyjne. Na końcu pracujemy już z “czystym pomysłem”, a nie konkretnym przedmiotem.

Jestem przekonany, że możesz odbyć z losową osobą dyskusję na temat “stołu”. Każdy z Was będzie mieć coś innego w głowie, ale pewna część wspólna co do wyglądu i zachowań jest “uniwersalna”. Ubierając to w inne słowa, możemy się różnić co do szczegółów implementacyjnych stołu, ale współdzielimy idee “stołowatości” (zestawu cech, które stół czynią stołem).

Gadatliwe API

Rzućmy okiem na to, z czym zostaliśmy po ostatnich rozważaniach odnośnie kompozycji.

// (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

Oczy przyzwyczajone do czytania kodu po szybkim skanowaniu tej struktury (nie aż tak rozbudowanej, jak potrafi być logika biznesowa) bardzo łatwo się odnajdą w tym, co jest “ważne”, a co “istotne, ale nieważne/powtarzalne”. Jednak nawet i to może stanowić problem. Im mniej jest tekstu do skanowania i zrozumienia, tym idee są dla nas bardziej czytelne.

Operatorpedia

Tak wiem, operator to jest śliski temat. Dużo osób ma utartą opinię o nich i o ich wpływie na czytelność kodu. Mnie osobiście przekonują argumenty pointfree, że złą sławę operatory zyskały z C++, gdzie często są przeciążone i ten sam “kształt” ma różne zachowanie (co może być pożądane!). Operatory są jednak z nami i dadzą nam coś, czego “litery alfabetu” ze swojej natury dać nie mogą.

W życiu mamy masę “operatorów”, z których korzystamy. Wyobrażacie sobie znaki drogowe jako zdania? Albo wskazówki jako wyrazy zamiast strzałek w metrze/na trasie/w nawigacji? Problem mamy tylko z tymi znakami, których nie znamy! Natomiast gdy już ten mały prożek się przejdzie, to bez problemu je czytamy i rozumiemy ich znaczenie. Jak widać zastosowane “tam, gdzie trzeba” i w “odpowiedniej ilości” ułatwiają życie.

Uciszanie gadatliwego API

Kompozycja jest bardzo fajna i elastyczna, jednak pod postacią funkcji jest gadatliwa. Jeżeli komuś to odpowiada, to super! Można zostać z tym, co się ma i cieszyć się tym. My chcielibyśmy tę hydraulikę schować. Nie ma możliwości na pozbycie się jej. To tak jakby pozbyć się instalacji elektrycznej z domu i oczekiwać, że wszystko będzie działało jak wcześniej. To co możemy zrobić, to schować to “pod operatorem”.

Zanim jednak rzucimy się na kompozycję, to “przy okazji” nazwiemy jeszcze jeden wzorzec często występujący “w kodzie”.

Pipe Forward |>

precedencegroup ForwardApplication {
    associativity: left
}

infix operator |>: ForwardApplication

public func |> <A, B>(x: A, f: (A) -> B) -> B {
    f(x)
}

Jak widać operator to zwykła funkcja, ale traktowana specjalnie przez kompilator, aby można ją było wywołać w troszeczkę inny sposób niż “normalnie” (więcej informacji w linkach).

Operator ten działa jak operator pałki “|” w terminalu. Bierze wartość z lewej strony i wkłada do funkcji z prawej. To jest to samo zachowanie, ale teraz możemy z niego korzystać we własnym kodzie!

Ten sam operator |> występuje w innych językach np. F#. Zobaczmy to na przykładzie:

1 |> incr |> { x in x * x } |> incr 

1
    |> incr
    |> { $0 * $0 }
    |> incr

Powolutku. Jak mamy nieprzyzwyczajone oczy, to może to wyglądać dziwnie.

Zaczynamy od tego, że mamy wartość “1”. Za pomocą operatora wkładamy ją do funkcji “incr”. Wynik tej operacji wkładamy do bloku zdefiniowanego inline. Wynik działania bloku ponownie wkładamy do funkcji “incr”. Na samym końcu (w obu przypadkach) otrzymujemy wynik operacji, czyli 5. Co ważne, za każdym razem funkcja jest wywołana i wynik przekazany dalej.

Jak widać istnieje dowolność w zapisie poszczególnych kroków. Z lewej do prawej lub z góry do dołu. Za każdym razem mamy czytelny zapis, skoncentrowany na tym co istotne, a nie na hydraulice, którą mamy zamkniętą pod operatorem.

Forward Composition >>>

Zajmijmy się teraz funkcją compose, którą też chcemy schować pod operatorem.

precedencegroup ForwardComposition {
    higherThan: ForwardApplication
    associativity: right
}

infix operator >>>: ForwardComposition

public func >>> <A, B, C>(
    _ f: @escaping (A) -> B,
    _ g: @escaping (B) -> C)
    -> (A) -> C {
        { a in a |> f |> g }
}

Implementacja operatora >>> jest identyczna z implementacją wcześniej omawianej funkcji “compose”. Jedyna różnica jest taka, że użyliśmy zdefiniowanego operatora |> do przekazywania wyniku jednej funkcji do drugiej.

Jest drobna różnica w “zachowaniu” operatora >>> w stosunku do |>. Operator komponowania funkcji pozwala na definiowanie “ciągów operacji” (pipeline’ów), które można definiować nie mając wartości (które “czekają” aż ta wartość się pojawi).

Przykład wart 1000 słów:

// (x + 3)^3 + 10
let expression = incr
    >>> incr
    >>> incr
    >>> { x in x * x * x }
    >>> { $0 + 10 }

type(of: expression)

-3 |> expression

Pod stałą expression mamy zdefiniowaną funkcję typu (Int) -> Int, która powstała po skomponowaniu ze sobą mniejszych funkcji. Gdy mamy wartość (-3), to możemy użyć wcześniej zdefiniowanego “procesu”.

Funkcje, jakie komponujemy, mają specyficzny kształt. Zawsze przyjmują jeden argument i zwracają jedną wartość. Niestety inne API, z jakimi pracujemy, nie mają tego kształtu. Jednak jest to coś, co można zaadresować trochę później.

Functorial Application <^>

precedencegroup FunctorialApplication {
    associativity: left
}

infix operator <^>: FunctorialApplication
public func <^> <A, B>(
    _ a: [A],
    _ f: @escaping (A) -> B
    )
    -> [B] {
         a.map(f)
}

public func <^> <A, B>(
    _ a: A?,
    _ f: @escaping (A) -> B
    )
    -> B? {
        a.map(f)
}

Operator mapy <^> został zdefiniowany dla dwóch typów, Array<A> oraz Optional<A>.

Funkcja map to coś więcej niż “owijka na for” i zasługuje na osobny artykuł (zapraszam na kanał, gdzie mamy odcinek poświęcony funkcji map). Wróćmy do tematu operatorów.

Ukrywamy pod tym operatorem wywołanie funkcji map na instancji, jaka jest przekazana z lewej strony oraz transformacji, jaka jest podana z prawej. To tłumaczy “<” i “>” z kształtu tego operatora. A o co chodzi z “^”? O funkcji map mówi się, że podnosi funkcję, jaką dostaje w argumencie, do “kontekstu” typu, na jakim jest zdefiniowana. Brzmi zagadkowo, ale, jak to w IT bywa, miliondolarowy pomysł opisujący parudolarowe rozwiązanie.

Zobaczmy użycie tego operatora w kodzie:

[1,2,3]
    <^> incr
    <^> incr
    <^> incr
// [4,5,6]

Funkcja incr ma typ (Int) -> Int. Natomiast ewidentnie pracujemy tu z tablicą liczb Array<Int> == [Int]. I to jest właśnie to “podnoszenie” (ang. lift). Funkcja incr nie ma pojęcia o tablicach, a dzięki funkcji map możemy jej użyć w kontekście tablicy.

Jeszcze jeden przykład:

Int?(42)
    <^> incr
    <^> incr
    <^> incr
// .some(45)

Ta sama historia tylko dla typu Optional<Int> == Int?. Co bardzo fajne: ponieważ mapa wie jak powinna podnosić funkcje, gdy pracuje z typem Optional, to dla wartości nil dostaniemy nil bez crashowania aplikacji! Bez żadnego guard / if let i innego szumu w kodzie!

Widać tu powtarzający się wzorzec. Funkcja map bierze wartość (wynik operacji) z lewej strony i wkłada do funkcji z prawej. Jeszcze inaczej: funkcja map abstrahuje nam wywołanie funkcji. Możemy to zapisać:

public func <^> <A, B>(
    _ a: A,
    _ f: @escaping (A) -> B
    )
    -> B {
        f(a) // dokładnie to samo co operator |>
}

Zupełnie niespodziewanie otrzymujemy kolejne narzędzia w skrzynce do kompozycji.

func id<A>(_ a: A) -> A { return a }

1
     |> [incr, incr, incr             ].reduce(id, compose) // 4
1
    <^> [incr, incr, incr, { $0 * 10 }].reduce(id, compose) // 40

1
     |> [incr, incr, incr             ].reduce(id, >>>) // 4
1
    <^> [incr, incr, incr, { $0 * 10 }].reduce(id, >>>) // 40

Słowo odnośnie funkcji id / identity. Robi ona tylko tyle, że zwraca podany argument. Może się to wydawać mało potrzebne, ale jeżeli w swoim kodzie masz fragment, który wygląda tak: { $0 } , to właśnie z niej korzystasz. Ta funkcja też jest dużo bardziej praktyczna i zasługuje na swój własny wpis (może kiedyś).

Wracając do przykładów. W jednej linijce powiedzieliśmy, że chcemy wsadzić “1” do całej tablicy funkcji zredukowanych/połączonych w jedną funkcję. Tu funkcja id i kompozycja tworzą razem monoid (matematycy się do tego dobrali wcześniej i ustalili nazwy, ale nie jest to takie straszne jak brzmi). Czyli funkcja id jest elementem neutralnym dla kompozycji. Jest tym, czym 1 dla mnożenia (*) i 0 dla dodawania (+).

Mamy w rękach bardzo ekspresyjne narzędzie, które pozwala mówić nam w deklaratywny sposób, co chcemy osiągnąć, a cała reszta jest gdzieś ukryta. Ona tam jest, ale już nie zajmuje tyle miejsca, co wcześniej.

One more thing…

Może się okazać, że to, co opiszę dalej w Swift 5.2 za sprawą tego proposala, nie będzie miało sensu. Ale uważam, że takie ćwiczenie jest bardzo cenne, ponieważ pokazuje, jak możemy dostosować coś pod siebie i że wcale nie musi to być trudne, ciężkie czy bardzo skomplikowane.

Swift KeyPath

Kod generowany przez kompilator jest bardzo fajny. Nie musimy go czytać, nie musimy go sprawdzać. I właśnie KeyPath jest takim fajnym unikalnym ficzerem Swift.

Szybko sobie przypomnijmy o co chodzi:

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

let nameKeyPath = Person.name
type(of: nameKeyPath) // KeyPath<Person, String>

let ageKeyPath: KeyPath<Person, Int> = Person.age

W pewnym uproszczeniu można powiedzieć, że mamy specjalne gettery na odpowiednie propety. Jest też specjalne API, które pozwala skonsumować taki KeyPath:

let person = Person(name: "Brajanusz", age: 42)

person[keyPath: nameKeyPath] // "Brajanusz"
person[keyPath: ageKeyPath]  // 42

Można powiedzieć, że KeyPath abstrahuje informacje o tym, jak wyciągnąć mniejszy fragment z całości. Konkretnie skupia się na tym mniejszym kawałku. I jeszcze inna perspektywa na to samo. Jest to zastosowanie single responsibility principle, gdzie ta odpowiedzialność to wyłącznie określenie “ścieżki”, po jakiej dobieramy się do mniejszego fragmentu w większej całości.

Aby też za bardzo nie odbiegać od tematu, to dla przyzwoitości wspomnę, że można też zapisywać, a nie tylko odczytywać za pomocą KeyPathów.

W chwili pisania tego artykułu wiele API w Swift przyjmuje jako argument funkcje/bloki, a nie KeyPath. Chcemy więc podnieść KeyPath do świata funkcji. Użyjemy do tego – a jakże – operatora:

prefix operator ^
public prefix func ^<Root, Value>(
    _ kp: KeyPath<Root,Value>)
    -> (Root) -> Value {
    return { root in root[keyPath: kp] }
}

Po prostu owijamy w funkcje API, jakie nam dostarczają KeyPathy. I dalej możemy pracować z tym jak ze zwykłą funkcją, tak jak pracowaliśmy wcześniej.

Przykład użycia:

person
    <^> ^.name
    <^> { $0.uppercased() }
// "BRAJANUSZ"

person
    <^> ^.name
    <^> String.uppercased
// "BRAJANUSZ"

Połączenie operatora <^> oraz ^ przy KeyPath po raz kolejny daje nam ekspresyjny sposób określania tego, co chcemy, a nie jak to chcemy zrobić. Obie linijki, w notacji nie pointfree (gdy odwołujemy się do zmiennej tu $0) i pointfree (bez odwołania do zmiennej tu: String.uppercased), można przeczytać jako: z obiektu “person” wyciągnij imię, a następnie zmień formatowanie na same wielkie litery. Kompozycja stała się “tańsza”. Nie zajmuje w kodzie tyle miejsca co wcześniej.

Ale to tylko początek. Wykorzystując nowo nabyte moce operatorów możemy:

let users = [
    Person(name: "Brajanusz", age: 42),
    Person(name: "Dżesika"  , age: 36),
    Person(name: "Waldemar" , age: 28)
]

users
    .map(^.name)
 // ["Brajanusz", "Dżesika", "Waldemar"]

users
    .map(^.age) 
// [42, 36, 28]

users
    .filter(^.age >>> { $0 > 30 })
    .map(^Person.name)
 // ["Brajanusz", "Dżesika"]

let isGreaterThan30Predicate: (Int) -> Bool = { $0 > 30 }

users
    .filter(^.age >>> isGreaterThan30Predicate)
    .map(^Person.name)
// ["Brajanusz", "Dżesika"]

Mając tablice użytkowników “users”, operatory i wykorzystując istniejące API, otrzymujemy bardzo deklaratywny sposób transformowania i filtrowania danych. W pierwszym przykładzie “wyciągamy” same imiona. W drugim wiek, ale potem dostajemy tablice imion osób, które mają więcej niż 30 lat. To wszystko w dosłownie dwóch linijkach kodu!

Podsumowanie

Operatorów nie wolno się bać. Nie są ani złe, ani nie stanowią cudownego rozwiązania na wszystko. To jest jak jazda samochodem, znane operatory/znaki drogowe czytamy szybko. Pomagają w nawigacji, mówią co mamy zrobić, a czego nie możemy robić. Gdy spotykamy rzadziej spotykany znak, to zwalniamy i jesteśmy bardziej uważni. Łatwo też przesadzić i trzeba starać się tak budować kod, aby nie przypominał skrzyżowania z milionem zjazdów i znaków.

Mamy do dyspozycji bardzo fajne narzędzie. I obiecuję, że nie ostatnie, jakie chcę pokazać. Natomiast tę przyjemność trzeba sobie troszeczkę dawkować. Polecam rzucić okiem na sekcję z linkami, gdzie znajdują się odnośniki do stron i materiałów, które sprawiły, że sam zacząłem chodzić “ścieżką operatora” i programowania funkcyjnego.


Linki i spojlery

  • pointfree.co – super 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.
  • Definicje operatorów z pointfree – polecam poszperać po całym repozytorium.
  • Daniel Steinberg, Dim Sum Thinking talk o Result type z użyciem operatorów.
  • Scott Wlaschin — The power of composition – to samo tylko w świecie F#. Natomiast dzięki operatorom ten kod wygląda identycznie! Nie trzeba uczyć się nowego języka tylko za darmo dostajemy kolejny.
  • Runes – biblioteka z operatorami do różnych często wykonywanych operacji.
  • Overturebiblioteka do ułatwienia sobie pracy przy komponowaniu funkcji.
  • F# for fun and profit – na 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.
  • NSHipster – o operatorach i o definiowaniu własnych
  • monoidy – warto o nich poczytać, ponieważ wiele problemów da się za ich pomocą zamodelować.
  • KeyPathKit – biblioteka do pracy z KeyPathami jak z funkcjami.
  • Człowiek i jego znaki – to jest taki offtopic, ale jak interesuje Cię komunikacja wizualna, to jest to super książka na temat znaków graficznych i tego, jak oddziałują na człowieka.

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

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://justjoin.it/blog/tansza-kompozycja" order_type="social" width="100%" count_of_comments="8" ]