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 transformacja i reduktor. 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:
Gdy reduce
zacznie pracę, tak będą wyglądały kolejne wywołania
przekazanej jej funkcji redukującej (operatora +
):
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:
Ż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:
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:
W przykładzie stworzyliśmy dwa transduktory: licznik
i sumator
. 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ą:
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ą o czterech argumentowościach: bez-, jedno-, dwu- i wieloargumentowej:
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:
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:
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ą:
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
:
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.
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.
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.
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.
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:
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.:
drop
– pomija pierwsze elementy na bazie liczby,drop-last
– pomija ostatnie elementy na bazie liczby,drop-while
– pomija pierwsze elementy na bazie predykatu,take
– wybiera pierwsze elementy na bazie liczby,take-while
– wybiera pierwsze elementy na bazie predykatu,take-nth
– wybiera pierwsze elementy na bazie kroku,filter
– wybiera elementy na bazie predykatu,remove
– pomija elementy na bazie predykatu,interpose
– wstawia separator między elementy,partition-all
– wydziela grupy elementów o określonej długości,partition-by
– wydziela grupy elementów na bazie predykatu,keep
– wybiera i przekształca elementy na bazie funkcji,keep-indexed
– wybiera i przekształca elementy na bazie funkcji,map
– przekształca wartość każdego elementu,map-indexed
– przekształca wartość każdego elementu,replace
– zastępuje wartości na podstawie mapy lub wektora,mapcat
– złącza sekwencje przekształcając wartości.