stats

Poczytaj mi Clojure, cz. 14

Transduktory

Grafika

Transduktory to funkcje, które służą do przekształcania sposobu działania funkcji redukujących. Pozwalają na tworzenie kolejnej warstwy abstrakcji, dzięki której można implementować transformacje wieloelementowych struktur danych niezależne od ich konkretnych charakterystyk.

Transduktory

Transduktor (ang. transducer) to funkcja wyższego rzędu, która przekształca (ang. transform) jedną funkcję redukującą w drugą. Owo przekształcenie polega na przyjęciu jako argument funkcji redukującej i zwróceniu nowej funkcji, która:

  • wywołuje oryginalną funkcję redukującą:
    • opcjonalnie zmieniając wartości przekazywanych argumentów;
    • opcjonalnie modyfikując rezultat tego wywołania.

Nazwa transduktor jest złożeniem wyrazów transformacjareduktor. Z kolei operacje redukcji wykonywane z użyciem przekształconych funkcji redukujących nazywamy transdukcjami (transformowanymi redukcjami). W odniesieniu do głównego terminu możemy też pokusić się o bardziej polsko brzmiące tłumaczenie: przetwornik.

Funkcje redukujące

Funkcja redukująca (ang. reducing function) to dwuargumentowa funkcja, która wywoływana jest dla każdego kolejnego elementu kolekcji i/lub sekwencji.

Redukcja zaczyna się od wywołania funkcji redukującej dla podanej wartości początkowej bądź wartości pierwszego elementu struktury wejściowej. Wynik tej operacji jest przekazywany jako pierwszy argument kolejnego wywołania funkcji redukującej. Drugim przekazywanym argumentem jest wartość następnego elementu. Rezultat działania wraz z wartością kolejnego elementu znów są przekazywane jako argumenty wywołania funkcji redukującej itd.

Mamy więc do czynienia ze skracaniem – wykonywaniem obliczeń na wartościach kolejnych elementów struktur lub sekwencji przy akumulowaniu rezultatów. Ostatni wynik jest wartością będącą efektem redukcji.

Funkcje redukujące są przekazywane jako jeden z argumentów wywołania operacji reduce (i podobnych), która w Clojure jest podstawowym sposobem zwijania (ang. fold) złożonych struktur o sekwencyjnym interfejsie dostępu. Dokładniej rzecz ujmując, mamy do czynienia z wariantem zwijania zwanym zwijaniem lewym (ang. left fold, skr. foldl).

Do reduce możemy opcjonalnie przekazać początkową wartość, od której zaczną się obliczenia. Jeżeli tego nie zrobimy, przeliczanie rozpocznie się od drugiego elementu podanej struktury, a pierwszy element zostanie użyty jako wartość początkowa.

Jeżeli podana kolekcja lub sekwencja jest pusta (nie istnieje pierwszy element), funkcja redukująca będzie wywołana bez argumentów. Jeżeli element jest tylko jeden, jego wartość zostanie zwrócona bez wywoływania przekazanej funkcji.

Spójrzmy na prosty przykład funkcji redukującej przekazywanej do wywołania reduce. Załóżmy, że chcemy zsumować liczby od 5 do 10. Funkcją redukującą będzie wtedy suma:

Przykład redukcji sumującej liczby z podanego zakresu
1
2
(reduce + [5 6 7 8 9 10])
; => 45
(reduce + [5 6 7 8 9 10]) ; => 45

Gdy reduce zacznie pracę, tak będą wyglądały kolejne wywołania przekazanej jej funkcji redukującej (operatora +):

1
2
3
4
5
6
;; funkcja: akumulator:  element:  rezultat:
(+              5           6)  ; => 11
(+             11           7)  ; => 18
(+             18           8)  ; => 26
(+             26           9)  ; => 35
(+             35          10)  ; => 45
;; funkcja: akumulator: element: rezultat: (+ 5 6) ; => 11 (+ 11 7) ; => 18 (+ 18 8) ; => 26 (+ 26 9) ; => 35 (+ 35 10) ; => 45

Pierwszy argument przyjmowany przez reduktor określamy mianem akumulatora (ang. accumulator), ponieważ akumuluje uzyskiwany wynik przekazywany między jego kolejnymi wywołaniami. Drugi argument nazwiemy wartością elementu z oczywistego powodu: jest to wartość aktualnie przetwarzanego elementu kolekcji bądź sekwencji.

Wyżej zaprezentowany przykład użycia reduce jest odpowiednikiem ręcznego sumowania kolejnych liczb z określonego przedziału, ale oczywiście nie wszystkie operacje da się tak prosto wyrazić. Spróbujmy użyć funkcji redukującej do obliczenia średniej arytmetycznej:

Przykład redukcji obliczającej średnią arytmetyczną
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
;; zakres liczb wyrażony sekwencją
(def zakres (range 5 12))

;; reduktor
(defn liczba-suma
  ([akumulator element]
   (-> akumulator
       (update 0 +' element)
       (update 1 inc))))

;; funkcja finalizująca
(defn licz-średnią [kolekcja]
  (apply / (reduce liczba-suma [0 0] kolekcja)))

;; wywołanie
(licz-średnią zakres)
; => 8
;; zakres liczb wyrażony sekwencją (def zakres (range 5 12)) ;; reduktor (defn liczba-suma ([akumulator element] (-> akumulator (update 0 +' element) (update 1 inc)))) ;; funkcja finalizująca (defn licz-średnią [kolekcja] (apply / (reduce liczba-suma [0 0] kolekcja))) ;; wywołanie (licz-średnią zakres) ; => 8

Żeby wyliczyć średnią, musimy najpierw zsumować uzyskane wartości i poznać ich liczbę. Chcemy zrealizować to w sposób następczy, będziemy więc musieli znaleźć sposób, aby między kolejnymi wywołaniami funkcji redukującej przekazywać dwie wartości.

Funkcja liczba-suma przyjmuje tu 2 argumenty. Pierwszym (element) jest wartość aktualnie przetwarzanego elementu sekwencji, a drugim akumulator (akumulator), zawierający rezultat pochodzący z jej poprzedniego wywołania (złożony z dwóch wartości).

Funkcja reduce dla każdego elementu przekazanej jej sekwencji zakres wywołuje funkcję redukującą liczba-suma, a jako jej argumenty przekazuje: rezultat jej wywołania na poprzednim elemencie i wartość elementu aktualnie przetwarzanego. Warto pamiętać, że jej pierwsze wywołanie użyje wartości dwóch pierwszych elementów podanej struktury, chyba że podamy wartość początkową (w przykładzie [0 0]).

W naszym przypadku akumulator będzie zawierał sumę do tej pory przetworzonych elementów i dodatkowo ich liczbę – dlatego użyty został dwuelementowy wektor: sumę będziemy zapisywali jako wartość jego pierwszego elementu, a liczbę jako wartość drugiego. Każde kolejne wywołanie funkcji redukującej będzie więc w pierwszym argumencie otrzymywało wektor z dwoma elementami, w drugim kolejną liczbę pochodzącą z sekwencji. Wartością zwracaną przez reduktor będzie również dwuelementowy wektor. Wartość pierwszego elementu wektora zostanie zaktualizowana przez dodanie wartości elementu pochodzącego z sekwencji, a wartość drugiego elementu przez zwiększenie o jeden. Używamy do tego funkcji update.

Spójrzmy, jak będą wyglądały kolejne wywołania funkcji redukującej:

1
2
3
4
5
6
7
8
;; funkcja:  akumulator: element:   rezultat:
(liczba-suma    [0 0]       5)  ; =>  [5 1]
(liczba-suma    [5 1]       6)  ; => [11 2]
(liczba-suma   [11 2]       7)  ; => [18 3]
(liczba-suma   [18 3]       8)  ; => [26 4]
(liczba-suma   [26 4]       9)  ; => [35 5]
(liczba-suma   [35 5]      10)  ; => [45 6]
(liczba-suma   [45 6]      11)  ; => [56 7]
;; funkcja: akumulator: element: rezultat: (liczba-suma [0 0] 5) ; => [5 1] (liczba-suma [5 1] 6) ; => [11 2] (liczba-suma [11 2] 7) ; => [18 3] (liczba-suma [18 3] 8) ; => [26 4] (liczba-suma [26 4] 9) ; => [35 5] (liczba-suma [35 5] 10) ; => [45 6] (liczba-suma [45 6] 11) ; => [56 7]

Widzimy, że każde wywołanie jako pierwszy argument (akumulator) przyjmuje dwuelementowy wektor i zwraca jego zmodyfikowaną wersję. Pierwszy element wektora to suma dotychczas przeliczonych wartości, a drugi ich liczba. Sumowanie jest możliwe, ponieważ jako drugi argument wywołania funkcji redukującej przekazywana jest wartość przetwarzanego elementu.

Funkcja apply na końcu przykładu dokonuje przekazania kolejnych wartości elementów wektora jako argumentów wywołania funkcji dokonującej dzielenia sumy wartości elementów przez ich liczbę. W rezultacie otrzymujemy średnią arytmetyczną wyrażanych sekwencją wartości.

Transformacje funkcji redukujących

Możliwość tworzenia kolejnej warstwy abstrakcji, polegająca na przekształcaniu funkcji redukujących z użyciem transduktorów, pozwala w wygodny i wydajny sposób zarządzać procesem redukcji.

Zastosowany w odniesieniu do funkcji redukującej transduktor może:

  • zmieniać jej wartość zwracaną,
  • zmieniać wartości przekazywanych jej argumentów,
  • wywoływać ją wiele razy dla pojedynczego elementu,
  • pomijać jej wywołania,
  • przerywać redukcję.

Powyższe czynności mogą być uwarunkowane wartością akumulatora (rezultatem dotychczasowych obliczeń), wartością aktualnie przetwarzanego elementu lub wartością zwracaną przez funkcję redukującą. W pewnych przypadkach warunki mogą bazować na wartości uzyskanej w drodze dereferencji utworzonego wcześniej obiektu referencyjnego wyrażającego współdzielony stan.

Transduktory możemy w łatwy sposób łączyć w pojedyncze funkcje wykorzystując złożenia.

Wiemy, że funkcja redukująca z ostatniego przykładu wykonuje dwie operacje: sumuje elementy i zlicza je. Spróbujmy stworzyć dwie osobne funkcje, z których każda będzie wykonywała jedną z nich. W ten sposób powstanie kod, którego części mogą być wielokrotnie stosowane również w innych funkcjach redukujących:

Przykład funkcji transformujących reduktor
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
(def zakres (range 5 12))

(defn licznik [f]
  (fn [akumulator element]
    (f (update akumulator 1 inc) element)))

(defn sumator [f]
  (fn [akumulator element]
    (f (update akumulator 0 +' element) element)))

(defn finalizer []
  (fn [a _] a))

(def reduktor
  ((comp licznik sumator finalizer)))

(defn licz-średnią-tr [kolekcja]
  (apply / (reduce reduktor [0 0] kolekcja)))

(licz-średnią-tr zakres)
; => 8
(def zakres (range 5 12)) (defn licznik [f] (fn [akumulator element] (f (update akumulator 1 inc) element))) (defn sumator [f] (fn [akumulator element] (f (update akumulator 0 +' element) element))) (defn finalizer [] (fn [a _] a)) (def reduktor ((comp licznik sumator finalizer))) (defn licz-średnią-tr [kolekcja] (apply / (reduce reduktor [0 0] kolekcja))) (licz-średnią-tr zakres) ; => 8

W przykładzie stworzyliśmy dwa transduktory: liczniksumator. Każda jako argument przyjmuje funkcję redukującą, a zwraca nową funkcję, w której pierwotna będzie wywołana, a dodatkowo dokonana zostanie modyfikacja przyjmowanych wartości.

Zdefiniowana została również funkcja finalizer, która zwraca funkcję przyjmującą wartość akumulatora i zwracającą go – jest ona użyta ze względów estetycznych, aby nie trzeba było jednego z transduktorów definiować w sposób obsługujący wartość zamiast funkcji. Wywołania naszych funkcji można by zagnieździć, jednak przejrzyściej wygląda ich złożenie realizowane z użyciem comp.

Powyższe zabiegi sprawiają, że możemy łatwo łączyć różne operacje, które razem staną się funkcją redukującą. Wykształcamy w ten sposób warstwę abstrakcji, dzięki której możemy tworzyć kaskady funkcji przeznaczone do wielokrotnego użycia.

Warto w tym miejscu zaznaczyć, że przedstawione funkcje nie są typowymi transduktorami, które mogłoby dobrze współdziałać z wbudowanymi mechanizmami języka Clojure przeznaczonymi specjalnie do obsługi tego typu konstrukcji. Różnią się od nich argumentowościami, co przekłada się na brak obsługi niektórych przypadków zastosowań.

Spójrzmy jeszcze, jak przedstawiają się wywołania anonimowych funkcji transformujących (oznaczone przedrostkiem x) i funkcji reduktor, na którą się składają:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
;; funkcja:   akumulator: element:
(reduktor        [0 0]      5)
  (x licznik     [0 0]      5)
  (x sumator     [0 1]      5)
  (x finalizer   [5 1]      5)
; => [5 1]

(reduktor        [5 1]      6)
  (x licznik     [5 1]      6)
  (x sumator     [5 2]      6)
  (x finalizer  [11 2]      6)
; => [11 2]

; […]

(reduktor       [45 6]     11)
  (x licznik    [45 6]     11)
  (x sumator    [45 7]     11)
  (x finalizer  [56 7]     11)
; => [56 7]
;; funkcja: akumulator: element: (reduktor [0 0] 5) (x licznik [0 0] 5) (x sumator [0 1] 5) (x finalizer [5 1] 5) ; => [5 1] (reduktor [5 1] 6) (x licznik [5 1] 6) (x sumator [5 2] 6) (x finalizer [11 2] 6) ; => [11 2] ; […] (reduktor [45 6] 11) (x licznik [45 6] 11) (x sumator [45 7] 11) (x finalizer [56 7] 11) ; => [56 7]

Tworzenie transduktorów

Tworzenie transduktorów polega na definiowaniu funkcji wyższego rzędu zachowujących się w określony sposób. Każda powinna jako argument przyjmować funkcję redukującą, a zwracać (również przeprowadzającą redukcję) funkcję wieloczłonowączterech argumentowościach: bez-, jedno-, dwu- i wieloargumentowej:

1
2
3
4
5
6
(fn [funkcja-redukująca]
  (fn                                      ; zwracana funkcja:
    ([] )                                 ; · wariant bezargumentowy
    ([akumulator] )                       ; · wariant jednoargumentowy
    ([akumulator element] )               ; · wariant dwuargumentowy
    ([akumulator element & elementy] )))  ; · wariant wieloargumentowy
(fn [funkcja-redukująca] (fn ; zwracana funkcja: ([] …) ; · wariant bezargumentowy ([akumulator] …) ; · wariant jednoargumentowy ([akumulator element] …) ; · wariant dwuargumentowy ([akumulator element & elementy] …))) ; · wariant wieloargumentowy

Zauważmy, że z przekazywanej funkcji redukującej możemy korzystać w zagnieżdżonej definicji zwracanej funkcji, ponieważ ta pierwsza jest kopiowana do domknięcia. W wyrażeniach definiujących każdą z argumentowości powinno dochodzić do wywołania przekazanej funkcji, chyba że w pewnych warunkach aktualnie przetwarzany element nie powinien być brany pod uwagę.

Spójrzmy na transduktor, który nie przekształca funkcji redukujących, ale po prostu pośredniczy w wywołaniach:

Przykład nieinwazyjnego transduktora
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
(defn xf-nic [reduktor]
  (fn
    ([]                   (reduktor))
    ([akumulator]         (reduktor akumulator))
    ([akumulator element] (reduktor akumulator element))
    ([akumulator element & elementy]
     (apply reduktor akumulator element elementy))))

(transduce xf-nic + [1 2 3 4 5])
; => 15
(defn xf-nic [reduktor] (fn ([] (reduktor)) ([akumulator] (reduktor akumulator)) ([akumulator element] (reduktor akumulator element)) ([akumulator element & elementy] (apply reduktor akumulator element elementy)))) (transduce xf-nic + [1 2 3 4 5]) ; => 15

Wariant bezargumentowy pierwotnie był zaplanowany jako użyteczny w procesie inicjowania przekształcania funkcji redukujących i miał być wywoływany tylko raz, na początku procesu. Zwracana wartość stawałaby się wtedy wartością początkową akumulatora po wywołaniu funkcji redukującej bez przekazywania jej jakichkolwiek argumentów. Do wywołania tego wariantu w praktyce nie dochodzi, ponieważ wbudowane funkcje języka Clojure nie używają tej argumentowości. Tak zdefiniowany wariant jest jednak użyteczny podczas ręcznego zastosowania transduktora w odniesieniu do funkcji redukującej, aby uzyskać domyślną dla niej wartość początkową (strukturę danych wywiedzioną z konkretnego zastosowania). Przykładami mogą tu być funkcje conj (uzyskamy wektor), into (wektor), + (0) czy * (1).

Wariant jednoargumentowy będzie użyty do finalizowania procesu przekształcania i wywołany tylko raz, na końcu redukcji. Przyda się na przykład do ostatecznego przeliczenia wartości czy usunięcia tymczasowych obiektów, które służyły do utrzymywania stanu między kolejnymi wywołaniami.

Wariant dwuargumentowy będzie wywoływany dla każdego pojedynczego kroku redukcji, czyli dla każdego z kolejnych elementów wejściowej kolekcji lub sekwencji. Jako pierwszy argument przekazywany będzie akumulator (wynik poprzednich obliczeń), a jako drugi wartość aktualnie przetwarzanego elementu. Wartość zwracana będzie przekazana jako akumulator w kolejnych wywołaniach funkcji redukującej, chociaż można doprowadzić do tego, aby wcale nie doszło do wywołania reduktora dla danego elementu – należy wtedy pominąć jego wywoływanie.

Wariant wieloargumentowy będzie wywoływany dla każdego pojedynczego kroku transdukcji, jeżeli funkcja transdukcyjna potrafi obsługiwać wiele kolekcji bądź sekwencji jednocześnie. Działanie tego wariantu powinno być takie samo, jak wariantu dwuargumentowego, z tą różnicą, że musi uwzględniać wiele źródeł elementów przy każdym kroku przeliczania.

Podczas tworzenia transduktorów warto nadawać im identyfikatory rozpoczynające się łańcuchem znakowym xf- (z ang. transform, skr. xform). Będzie to zgodne z przyjętą konwencją nazewniczą.

Zgodnie z inną konwencją ewentualne złożenia transduktorów powinny być wykonywane od lewej do prawej. Warto więc definiować transduktory w taki sposób, aby umieszczone w nich odwołania do dwuargumentowego wariantu funkcji redukującej były ostatnimi wywołaniami, a modyfikacjom podlegały przekazywane argumenty – oczywiście, jeżeli jest to możliwe w przyjętych przekształceniach.

Spróbujmy zmodyfikować ostatni przykład, dodając zliczanie elementów:

Przykład transduktora zachowującego stan
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
(defn xf-licznik [reduktor]
  (let [licznik (volatile! 0)]
    (fn
      ([]
       (reduktor))
      ([akumulator]
       (list (reduktor akumulator) @licznik))
      ([akumulator element]
       (vswap! licznik inc)
       (reduktor akumulator element))
      ([akumulator element & elementy]
       (vswap! licznik inc)
       (apply reduktor akumulator element elementy)))))

(transduce xf-licznik + [1 2 3 4 5])
; => (15 5)
(defn xf-licznik [reduktor] (let [licznik (volatile! 0)] (fn ([] (reduktor)) ([akumulator] (list (reduktor akumulator) @licznik)) ([akumulator element] (vswap! licznik inc) (reduktor akumulator element)) ([akumulator element & elementy] (vswap! licznik inc) (apply reduktor akumulator element elementy))))) (transduce xf-licznik + [1 2 3 4 5]) ; => (15 5)

Skorzystaliśmy z obiektu typu Volatile, dzięki któremu stworzyliśmy licznik zwiększany za każdym wywołaniem zwracanego transduktora. Wartość bieżąca licznika aktualizowana jest w wariancie dwuargumentowym. W ten sposób zbudowaliśmy tzw. transduktor stanowy (ang. stateful transducer). Zauważmy, że bez utrzymywania zmiennego stanu musielibyśmy w akumulatorze przechowywać złożoną strukturę danych (np. wektor), aby pamiętać wartość licznika wywołań.

Ponieważ mamy możliwość obsługi finalizowania procesu redukcji, w wariancie jednoargumentowym funkcji zwracamy wartość bieżącą licznika jako drugi element utworzonej listy, a jej pierwszym elementem jest rezultat obliczeń pochodzący z akumulatora.

Idąc tym tropem, możemy pokusić się o zdefiniowanie transduktorów, które pomogą nam wyliczać średnią arytmetyczną:

Przykład transduktorów liczących średnią arytmetyczną
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
(defn xf-licznik [reduktor]
  (let [licznik (volatile! 0)]
    (fn
      ([]
       (reduktor))
      ([akumulator]
       (list (reduktor akumulator) @licznik))
      ([akumulator element]
       (vswap! licznik inc)
       (reduktor akumulator element))
      ([akumulator element & elementy]
       (vswap! licznik inc)
       (apply reduktor akumulator element elementy)))))

(defn xf-dzielnik [reduktor]
  (fn
    ([]                   (reduktor))
    ([akumulator]         (apply / (reduktor akumulator)))
    ([akumulator element] (reduktor akumulator element))
    ([akumulator element & elementy]
     (apply reduktor akumulator element elementy))))

(def xf-średnia
  (comp xf-dzielnik xf-licznik))

(transduce xf-średnia +' (range 5 12))
; => 8
(defn xf-licznik [reduktor] (let [licznik (volatile! 0)] (fn ([] (reduktor)) ([akumulator] (list (reduktor akumulator) @licznik)) ([akumulator element] (vswap! licznik inc) (reduktor akumulator element)) ([akumulator element & elementy] (vswap! licznik inc) (apply reduktor akumulator element elementy))))) (defn xf-dzielnik [reduktor] (fn ([] (reduktor)) ([akumulator] (apply / (reduktor akumulator))) ([akumulator element] (reduktor akumulator element)) ([akumulator element & elementy] (apply reduktor akumulator element elementy)))) (def xf-średnia (comp xf-dzielnik xf-licznik)) (transduce xf-średnia +' (range 5 12)) ; => 8

W powyższym przykładzie funkcja xf-średnia jest złożeniem funkcji xf-dzielnik oraz xf-licznik. Zadaniem pierwszej jest podzielenie uzyskanej sumy wartości przez ich liczbę, a zadaniem drugiej uzyskanie tej liczby. W celu sumowania wartości korzystamy z przekazanego do transduce operatora dodawania.

Porównajmy czasy przetwarzania dla próbki złożonej z 10 milionów pierwszych liczb naturalnych przy użyciu zdefiniowanych w poprzednim przykładzie transduktorów i przy użyciu zaprezentowanej na początku strategii użycia funkcji reduce:

Porównanie czasów realizacji różnych sposobów redukcji
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
(def zakres (range 1 100000001))

;; z użyciem transduce, transduktorów i obiektu Volatile:
;;
(time (transduce xf-średnia +' zakres))
; >> "Elapsed time: 3233.649965 msecs"

;; z użyciem reduce i akumulowanego wektora:
;;
(time (licz-średnią zakres))
; >> "Elapsed time: 7871.497554 msecs"

;; z użyciem reduce, quasi-transduktorów i akumulowanego wektora:
;;
(time (licz-średnią-tr zakres))
; >> "Elapsed time: 80220.751329 msecs"
(def zakres (range 1 100000001)) ;; z użyciem transduce, transduktorów i obiektu Volatile: ;; (time (transduce xf-średnia +' zakres)) ; >> "Elapsed time: 3233.649965 msecs" ;; z użyciem reduce i akumulowanego wektora: ;; (time (licz-średnią zakres)) ; >> "Elapsed time: 7871.497554 msecs" ;; z użyciem reduce, quasi-transduktorów i akumulowanego wektora: ;; (time (licz-średnią-tr zakres)) ; >> "Elapsed time: 80220.751329 msecs"

W przypadku wywołania transduce i wykorzystania transduktorów będziemy mieć do czynienia z zyskiem wydajnościowym, ponieważ operacje na kolekcjach są w Clojure zazwyczaj implementowane sekwencyjnie. Jeżeli mamy do czynienia z wieloma osobnymi operacjami redukującymi i przekazywaniem rezultatów z jednej do drugiej, w pamięci powstają sekwencyjne struktury pośrednie, na których obsługę trzeba dodatkowo poświęcać czas. Gdy dokonamy złożenia operacji, sekwencja odzwierciedlająca elementy kolekcji będzie tylko jedna, a więc będziemy mieli do czynienia z pojedynczym jej inicjowaniem.

W przedstawionych przykładach zysk wydajnościowy wynikał również z zastosowania szybkiego licznika bazującego na współdzielonym stanie i tożsamości referencyjnej. Mogliśmy zmieniać jego wartość bez konieczności aktualizowania złożonej, trwałej struktury akumulującej rezultaty.

Użytkowanie transduktorów

Język Clojure od wydania 1.7 zawiera kilka wbudowanych funkcji służących do realizowania transdukcji, jak i wiele funkcji będących generatorami transduktorów.

Realizowanie transdukcji

Transdukcja, transduce

Funkcja transduce pozwala przeprowadzić redukcję podanej kolekcji lub sekwencji z transformacją funkcji redukującej. Działa bardzo podobnie do reduce, ale poza operacją redukującą umożliwia wprowadzenie transduktora, który może być pojedynczy lub stanowić złożenie innych transduktorów.

Użycie:

  • (transduce transduktor reduktor kolekcja),
  • (transduce transduktor reduktor wartość-początkowa kolekcja).

Pierwszym argumentem powinien być transduktor, czyli funkcja wyższego rzędu, która jako argument przyjmuje funkcję redukującą i zwraca taką funkcję jako wartość. Poza wywoływaniem funkcji redukującej zwracana funkcja może dokonywać dodatkowych operacji na uzyskanym wyniku.

Kolejnym obowiązkowym argumentem powinna być funkcja redukująca. Będzie ona użyta względem kolejnych elementów kolekcji (lub sekwencji) podanej jako ostatni argument wywołania transduce.

Funkcja redukująca nie będzie wywoływana bezpośrednio, lecz przez przekazanie jej obiektu do transduktora, a dopiero zwracana przez niego funkcja będzie uruchamiana dla każdego elementu podanej kolekcji lub sekwencji.

Wywoływanie transduktora (a więc pośrednio funkcji redukującej) będzie się odbywało w taki sposób, że najpierw jako pierwszy argument (akumulator) zostanie przekazana wartość początkowa, a jako drugi wartość pierwszego elementu. Kolejne wywołanie transduktora jako pierwszy argument otrzyma wartość poprzedniego wywołania, a jako drugi wartość kolejnego elementu. Ostatnie wywołanie transduktora stanie się rezultatem całej operacji.

Jeżeli struktura wejściowa jest pusta, zwrócona będzie tylko wartość początkowa.

W wariancie pięcioargumentowym funkcja transduce umożliwia podanie wartości początkowej (akumulatora) jako trzeci argument.

W wariancie czteroargumentowym wartość początkowa będzie rezultatem bezpośredniego wywołania funkcji redukującej bez podawania jej argumentów. Jest to istotna różnica w stosunku do reduce, która użyje pierwszego i drugiego elementu, gdy nie podano wartości początkowej

Wartością zwracaną przez transduce jest rezultat redukcji, czyli wartość zwracana przez wywołanie transduktora w wariancie jednoargumentowym, gdzie wartością przekazywanego mu argumentu jest rezultat ostatniego wywołania transduktora dla akumulatora i elementu przeliczanej kolekcji lub sekwencji.

Przykłady użycia funkcji transduce
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
(transduce (map inc) conj '(1 2 3))
; => [2 3 4]

(def xf (comp                ; złożenie transduktorów:
         (filter even?)      ; · obsługa tylko wartości parzystych 
         (map #(str % " "))  ; · przekształcenie do napisu ze spacją
         (take 10)))         ; · przerwanie redukcji po 10 elementach

(transduce xf str (range))
; => "0 2 4 6 8 10 12 14 16 18 "
(transduce (map inc) conj '(1 2 3)) ; => [2 3 4] (def xf (comp ; złożenie transduktorów: (filter even?) ; · obsługa tylko wartości parzystych (map #(str % " ")) ; · przekształcenie do napisu ze spacją (take 10))) ; · przerwanie redukcji po 10 elementach (transduce xf str (range)) ; => "0 2 4 6 8 10 12 14 16 18 "

W linii nr 1 widzimy, że jako funkcję redukującą podaliśmy conj, która służy do dodawania elementu do kolekcji. Do jakiej kolekcji element ma być dodany, skoro nie podaliśmy wartości początkowej? Na początku transdukcji dojdzie do bezargumentowego wywołania conj, która zwróci pusty wektor.

Wyjaśnianie transdukcji, eduction

Funkcja eduction jest odpowiednikiem funkcji reductions służącej do obsługi konwencjonalnych redukcji. Dzięki niej możemy poznać kolejne etapy redukcji przeprowadzanej z użyciem transduce, czyli wartości akumulatora zwracane przez podaną funkcję redukującą.

Funkcja eduction działa w podobny sposób, co transduce, ale zamiast pojedynczej wartości tworzy sekwencję wartości pośrednich. Nie przekazujemy jej funkcji redukującej, a tylko transduktory, więc nie dojdzie do skrócenia rezultatu, lecz do powstania zestawu kolejnych wartości przekształcanych przez wywołana funkcji transformujących.

Użycie:

  • (eduction transduktor… kolekcja).

Funkcja przyjmuje jeden lub więcej obiektów funkcyjnych będących transduktorami, a ostatnim przekazywanym argumentem powinna być kolekcja lub sekwencja, na której zostaną zastosowane.

Przykłady użycia funkcji eduction
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
(eduction            ; wyjaśnienie zastosowania transduktorów:
 (filter even?)      ; · obsługa tylko wartości parzystych
 (map #(str % " "))  ; · przekształcenie elementu do napisu
 (take 10)           ; · przerwanie redukcji po 10 elementach
 (range))            ; · sekwencja wejściowa

; => ("0 " "2 " "4 " "6 " "8 " "10 " "12 " "14 " "16 " "18 ")

(eduction            ; wyjaśnienie zastosowania transduktorów:
 (filter even?)      ; · obsługa tylko wartości parzystych
 (take 5)            ; · przerwanie redukcji po 10 elementach
 (range))            ; · sekwencja wejściowa

; => (0 2 4 6 8)
(eduction ; wyjaśnienie zastosowania transduktorów: (filter even?) ; · obsługa tylko wartości parzystych (map #(str % " ")) ; · przekształcenie elementu do napisu (take 10) ; · przerwanie redukcji po 10 elementach (range)) ; · sekwencja wejściowa ; => ("0 " "2 " "4 " "6 " "8 " "10 " "12 " "14 " "16 " "18 ") (eduction ; wyjaśnienie zastosowania transduktorów: (filter even?) ; · obsługa tylko wartości parzystych (take 5) ; · przerwanie redukcji po 10 elementach (range)) ; · sekwencja wejściowa ; => (0 2 4 6 8)

Sekwencja transdukcyjna, sequence

Funkcja sequence może być użyta do generowania leniwych sekwencji na bazie kolekcji bądź sekwencji, w stosunku do których zastosowano przekształcenia z użyciem transduktorów.

Użycie:

  • (sequence transduktor kolekcja & kolekcja…).

Pierwszym argumentem powinien być funkcyjny obiekt transduktora, a kolejnymi kolekcje bądź sekwencje, których wartości kolejnych elementów zostaną przekształcone z jego użyciem.

Jeżeli podano więcej niż jedną strukturę wejściową, transduktor musi obsługiwać dodatkowe argumenty elementarne, ponieważ iteracja będzie odbywała się dla kolejnych elementów z każdej ze struktur jednocześnie.

Wartością zwracaną przez sequence jest leniwa sekwencja wartości, które są efektem zastosowania transduktora na elementach kolekcji wejściowych.

Przykład użycia funkcji sequence
1
2
3
4
5
6
7
(def xf (comp                ; złożenie transduktorów:
         (filter even?)      ; · obsługa tylko wartości parzystych 
         (map str)           ; · przekształcenie do napisu
         (take 10)))         ; · przerwanie redukcji po 10 elementach

(sequence xf (range))
; => ("0" "2" "4" "6" "8" "10" "12" "14" "16" "18")
(def xf (comp ; złożenie transduktorów: (filter even?) ; · obsługa tylko wartości parzystych (map str) ; · przekształcenie do napisu (take 10))) ; · przerwanie redukcji po 10 elementach (sequence xf (range)) ; => ("0" "2" "4" "6" "8" "10" "12" "14" "16" "18")

Aplikowanie transdukcji, into

Funkcja into pozwala dołączać elementy jednej kolekcji do drugiej. Jeżeli jako drugi argument podamy transduktor, zostanie on użyty do przekształcenia wartości każdego z elementów.

Użycie:

  • (into kolekcja-docelowa transduktor kolekcja-źródłowa).

Pierwszym argumentem powinna być kolekcja, do której zostaną dołączone elementy, drugim transduktor, a trzecim kolekcja stanowiąca źródło elementów.

Przykład użycia funkcji into
1
2
(into [1 2 3] (map (partial + 3)) [1 2 3])
; => [1 2 3 4 5 6]
(into [1 2 3] (map (partial + 3)) [1 2 3]) ; => [1 2 3 4 5 6]

Wbudowane transduktory

Wraz z wersją 1.7 wzorcowej implementacji języka nie tylko określono zasady tworzenia transduktorów, ale również zmieniono wiele wbudowanych funkcji służących do przekształcania kolekcji i sekwencji w taki sposób, że mogą one być generatorami transduktorów. Wystarczy nie przekazywać im kolekcji, na których mają operować, a wartościami zwracanymi będą odpowiednie funkcje transdukcyjne stworzone na bazie przekazanych operatorów:

Przykłady użycia wbudowanych generatorów funkcji transdukcyjnych
1
2
3
4
5
6
7
8
(def zakres (range 1 11))                         ; sekwencja wejściowa

(def xf-parzyste (filter even?))                  ; filter jako generator
(def xf-razy-dwa (map (partial * 2)))             ; map jako generator
(def xf          (comp xf-parzyste xf-razy-dwa))  ; złożenie funkcji

(transduce xf + zakres)                           ; transdukcja
; => 60
(def zakres (range 1 11)) ; sekwencja wejściowa (def xf-parzyste (filter even?)) ; filter jako generator (def xf-razy-dwa (map (partial * 2))) ; map jako generator (def xf (comp xf-parzyste xf-razy-dwa)) ; złożenie funkcji (transduce xf + zakres) ; transdukcja ; => 60

W powyższym przykładzie użyliśmy funkcji filter oraz map, aby wygenerować transduktory. Pierwszy (xf-parzyste) sprawia, że z zestawu liczb od 1 do 10 będą wybrane tylko liczby parzyste, a drugi (xf-razy-dwa), że każda z nich będzie podczas przetwarzania mnożona przez 2 (stosujemy częściowe zastosowanie funkcji, aby wygenerować funkcję mnożącą przez 2 przekazywaną do map, zamiast korzystać z lambda-wyrażenia).

W kolejnym kroku dokonujemy złożenia transduktorów i powstałą funkcję nazywamy xf.

Ostatnim wyrażeniem przykładu jest wywołanie funkcji transduce, która działa bardzo podobnie do reduce, ale została stworzona specjalnie z myślą o transduktorach. Przekazujemy jej finalną operację, którą jest sumowanie powstałego zestawu liczb.

Popularne funkcje wyższego rzędu, które pomogą nam w tworzeniu funkcji redukujących i zwrócą transduktory na bazie przeprowadzanych przekształceń to m.in.:

Jesteś w sekcji .
Tematyka:

Taksonomie: