Poczytaj mi Clojure, cz. 13: Transduktory

Transduktory to funkcje, które służą do przekształcania funkcji redukujących. Pozwalają one 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 zwykle polega na przyjęciu jako argument funkcji redukującej i zwróceniu funkcji, która:

  • zmienia wartości wybranych, przyjmowanych argumentów;
  • wywołuje przekazaną funkcję redukującą, przekazując jej zmienione wartości;
  • zwraca 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).

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 wartości początkowej i pierwszego elementu struktury wejściowej. Wynik operacji jest przekazywany jako pierwszy argument kolejnego wywołania funkcji redukującej. Drugim przekazywanym argumentem jest wartość następnego elementu. Rezultat działania razem z wartością następnego elementu znów są przekazywane jako argumenty wywołania funkcji redukującej itd. Mamy więc do czynienia ze skracaniem – wykonywaniem obliczeń na kolejnych elementach struktur 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 skracania (redukowania) złożonych struktur o sekwencyjnym interfejsie dostępu.

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, zostanie on zwrócony bez jej wywoływania.

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

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

Pierwszy argument przyjmowany przez reduktor określamy mianem akumulatora, ponieważ akumuluje do tej pory uzyskany wynik, który jest przekazywany między jego kolejnymi wywołaniami. Drugi argument nazwiemy wartością elementu z oczywistego powodu – jest to wartość aktualnie przetwarzanego elementu kolekcji lub 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

Ż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 redukująca 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 działa tak, że dla każdego elementu przekazanej jej kolekcji lub sekwencji wywołuje podaną funkcję redukującą (tu liczba-suma), a jako jej argumenty przekazuje: rezultat jej wywołania na poprzednim elemencie i wartość elementu aktualnie przetwarzanego. Implementując własną funkcję redukującą, będziemy więc obsługiwali wspomniane dwa argumenty. Warto pamiętać, że jej pierwsze wywołanie użyje 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ł wektor: sumę będziemy zapisywali jako jego pierwszy element, a liczbę jako drugi. Każde kolejne wywołanie funkcji redukującej będzie więc w pierwszym argumencie otrzymywało dwuelementowy wektor, w drugim kolejną liczbę pochodzącą z sekwencji. Wartością zwracaną będzie też dwuelementowy wektor. Wartość pierwszego elementu wektora zostanie zaktualizowana przez dodanie do niej wartości elementu pochodzącego z sekwencji, a wartość drugiego elementu przez zwiększenie jej 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]

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 przekształcenia wektora w argumenty wywołania funkcji dzielącej sumę wartości elementów przez ich liczbę. W rezultacie otrzymujemy średnią arytmetyczną podanej sekwencji 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 odczycie globalnego stanu jakiegoś obiektu referencyjnego, który pomaga w obliczeniach.

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

W przykładzie stworzyliśmy dwa transduktory: liczniksumator. Każda jako argument przyjmuje funkcję redukującą, a zwraca nową funkcję, w której tamta 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 wielokrotnego wykorzystania.

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 zastosowania.

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]

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ć (także 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

Zauważmy, że z podawanej w argumencie funkcji redukującej możemy korzystać w zagnieżdżonej definicji zwracanej funkcji, ponieważ ta pierwsza jest przekazywana przez domknięcie. 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

Wariant bezargumentowy będzie użyty do inicjowania procesu przekształcania funkcji redukujących i wywołany tylko raz, na początku transdukcji. Zwracana wartość będzie wartością początkową akumulatora i powinna dokonywać wywołania funkcji redukującej bez podania argumentów. Do wywołania dojdzie tylko wtedy, gdy nie zostanie przekazana wartość początkowa akumulatora, ani nie zostanie ona uzyskana z bezargumentowego wywołania funkcji redukującej. W praktyce więc nigdy nie dojdzie do wywołania tego wariantu w procesie transdukcji (przynajmniej nie w wydaniu 1.7.0 wzorcowej implementacji języka Clojure), jednak powinniśmy definiować go, jeżeli chcemy zachować kompatybilność z sygnaturą funkcji redukującej.

Wariant jednoargumentowy będzie użyty do finalizowania procesu przekształcania funkcji redukującej i wywołany tylko raz, na końcu transdukcji. Można go użyć 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 transdukcji, 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ż w niektórych przypadkach można doprowadzić do tego, aby wcale nie doszło do jej wywołania dla danego elementu – należy wtedy pominąć jej wywoływanie.

Wariant wieloargumentowy będzie wywoływany dla każdego pojedynczego kroku transdukcji, jeżeli funkcja transdukcyjna potrafi obsługiwać wiele kolekcji. 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 nazwy 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)

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 utworzyliś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

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 (a właściwie jego obiektu funkcyjnego).

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"

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, w pamięci powstają sekwencyjne struktury pośrednie. Gdy dokonamy złożenia operacji, sekwencja będzie tylko jedna.

Poza tym w tych konkretnych przykładach zyskaliśmy na czasie przede wszystkim z powodu zastosowania szybkiego licznika, którego stan mogliśmy zmieniać bez konieczności aktualizowania złożonej struktury (wektora) stanowiącego akumulator rezultatów. Aktualizowanie elementów wektora to bowiem kilka dodatkowych wywołań funkcji dla każdego kroku.

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ć złożeniem 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 otrzymanej funkcji, 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 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 "

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)

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")

Aplikowanie transdukcji, into

Funkcja into pozwala dołączać elementy jednej kolekcji do drugiej. Jeżeli jako drugi argument podamy transduktor, to 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]

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 struktur, na których mają operować, a wartościami zwracanymi będą odpowiednie funkcje transdukcyjne:

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?))                  ; f. filter jako generator
(def xf-razy-dwa (map (partial * 2)))             ; f. 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.

Odwzorowywanie, map i map-indexed

Dzięki funkcji map możemy przekształcać wartości elementów na podstawie podanej funkcji transformującej.

Użycie:

  • (map         transformator),
  • (map-indexed transformator).

Jako generator transduktorów przyjmuje ona jeden argument, którym powinna być jednoargumentowa funkcja – będzie ona wywoływana dla kolejnych elementów, a zwracane przez nią wartości zastąpią wartości.

Odmianą funkcji map, która do funkcji przekształcającej przekazuje dodatkowo numer kolejny elementu (poczynając od 0) jest map-indexed.

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

(sequence  (map inc)   [1 2 3])
; => (2 3 4)

(transduce (map-indexed #(str %1 "-" %2))
           conj
           '(7 8 9))
; => ["0-7" "1-8" "2-9"]

[TBW]

Ten materiał jest częścią większej całości, którą można zobaczyć odwiedzając poniższy odnośnik:

Komentarze