W jednym z wcześniejszych wpisów zajmowaliśmy się funktorami. Wiemy już, że w Haskellu reprezentowane są one przez klasę typów Functor, dzięki której możemy aplikować funkcje do danych zapakowanych w różne kontenery. Dzisiaj porozmawiamy sobie o kolejnym istotnym pojęciu w świecie programowania funkcyjnego – o funktorach aplikatywnych.

Najważniejsze różnice w stosunku do zwykłych funktorów można streścić w dwóch zdaniach:

  • skonteneryzowane są nie tylko argumenty funkcji, ale i sama funkcja
  • możemy używać funkcji z wieloma parametrami

Zanim przejdziemy dalej, od razu wyjaśnię, że takie typy jak lista, Maybe oraz Either, które ostatnio rozpatrywaliśmy jako przykłady funktorów, są również funktorami aplikatywnymi. Z tego też względu licznie będą się one pojawiać we fragmentach kodu w dalszej części wpisu.

Definicja

Poznawanie funktorów aplikatywnych najłatwiej będzie zacząć od przyjrzenia się definicji klasy Applicative (funktory aplikatywne to po angielsku applicative functors, stąd właśnie taka nazwa tej klasy), a konkretniej dwóm jej najistotniejszym funkcjom:

pure

Już sama sygnatura funkcji pure wskazuje, że nie ma tu nic skomplikowanego. Jako argument przekazujemy wartość, a w zamian otrzymujemy tę wartość zapakowaną w funktor (aplikatywny). Na takiej wartości możemy z kolei operować tak, jak widzieliśmy to na przykładach w artykule o funktorach. Na przykład, możemy użyć funkcji fmap, bowiem musi być ona zaimplementowana dla każdego typu, który jest instancją Applicative (co wskazane jest w pierwszej linii naszej definicji typeclassy – każdy funktor aplikatywny musi być też instancją klasy Functor).

<*>

Funkcję oznaczoną operatorem <*> niekiedy nazywa się również apply, zaś jej działanie jest bardzo podobne do funkcji fmap (oznaczanej też operatorem <$>). Zresztą, nie ma co gadać – porównajmy sobie ich sygnatury:

Jedyna różnica polega tu na tym, że – jak już wspomniałem – funkcja a -> b jest tutaj skonteneryzowana. Dla zwykłych funktorów mogliśmy napisać:

natomiast dla funktora aplikatywnego użyjemy np. takiego kodu:

lub też, wykorzystując poznaną chwilkę temu funkcję pure:

Funkcje wieloargumentowe

Sporą różnicą w porównaniu ze zwykłymi funktorami jest możliwość aplikowania funkcji wieloargumentowych. Oczywiście nic nie stoi na przeszkodzie, aby przekazać do fmap tego typu funkcję, np.:

…ale co dalej? Zmienna f będzie zawierała funkcję opakowaną w kontekst Maybe, czyli Just (+10). Ograniczając się do samych funktorów, nie wykorzystamy takiej funkcji w łatwy i elegancki sposób.

Na szczęście, korzystając z funktorów aplikatywnych, możemy bez problemu operować na funkcjach o dowolnej liczbie argumentów:

W pierwszym przykładzie używamy zdefiniowanej przez nas funkcji trójargumentowej – najpierw pakując ją w kontekst (zamiast pure moglibyśmy w tym przypadku explicite użyć konstruktora Right), a następnie podajemy argumenty, oddzielając je operatorem <*>.

Co jednak dzieje się w linii nr 5, w której używamy zarówno operatora <$>, jak i <*>? Zaczynamy od zwyczajnej funkcji dodawania (+), operującej na zwyczajnych wartościach (nigdzie nie zapakowanych). Przy pomocy operatora <$> aplikujemy do niej pierwszy argument i tak częściowo zaaplikowaną funkcję konteneryzujemy (czyli w tym momencie mamy Just (+10)). Następnie, traktując ją jako funktor aplikatywny, aplikujemy pozostałe argumenty (w tym wypadku – jeden argument).

Tego typu operacje są możliwe, ponieważ operator <$> jako pierwszy argument przyjmuje skonteneryzowaną funkcję, a zwracaną wartością również może być funkcja umieszczona wewnątrz kontenera (z tym, że z zaaplikowaną większą liczbą argumentów).

Funkcje pomocnicze

Klasa Applicative (oraz sam moduł Control.Applicative) definiuje nieco więcej funkcji niż dwie, które wymieniłem wyżej, ale w przypadku pozostałych metod zazwyczaj wystarczające są ich domyślne implementacje. Poznajmy zatem ich działanie.

Funkcje lift

Pierwsza grupa, to rodzeństwo funkcji liftA, które ułatwiają aplikowanie funkcji dwu- oraz trójargumentowych. Jeśli f to jakaś funkcja, która przyjmuje dwa argumenty, to zamiast pisać:

możemy skrócić to wyrażenie do:

W analogiczny sposób działają funkcje liftA3 (dla funkcji trójargumentowych) oraz liftA (dla jednoargumentowych, więc jej działanie jest identyczne jak fmap). Nazwa tych funkcji – lift (po angielsku: podniesienie) bierze się stąd, że ich działaniem jest podnoszenie zwykłej funcji do funkcji skonteneryzowanej i działającej na funktorach.

Operatory

Jak może pamiętasz z artykułu o funktorach, oprócz operatora <$> mieliśmy tam też operator <$, którego rezultatem był tylko jeden z dwóch podanych argumentów. Podobne operatory istnieją też dla funktorów aplikatywnych. Są to <* oraz *>:

Powyższy przykład demonstruje ich działanie, ale bynajmniej nie pokazuje zastosowania. Do czego przydają się takie dziwne funkcje? Dzięki nim możemy wywołać pewną sekwencję funkcji, jednocześnie pomijając rezultat jednej z nich. Może być to przydatne kiedy mamy do czynienia z funkcjami posiadającymi efekty uboczne.

Ostatni z dziwnych operatorów aplikatywnych to <**>. Różni się on od <*> kolejnością przyjmowanych argumentów. Porównajmy sygnatury obu tych funkcji:

Krotki po raz kolejny

Krotki są w Haskellu ciekawymi i czasami dość nieoczywistymi w swoim zachowaniu strukturami. Wynika to z faktu, że – w przeciwieństwie do list – mogą one przechowywać elementy o różnych typach. Już przy funktorach natknęliśmy się na sytuację, w której zaaplikowanie funkcji do dwulementowej krotki skutkowało działaniem tylko na jej ostatnim elemencie:

Jeśli zależy nam na transformacji obu elementów, to jednym z możliwych rozwiązań są (również wspomniane już we wcześniejszym artykule) bifunktory, dostarczające funkcję bimap:

A co stanie się, jeśli potraktujemy krotki jako funktory aplikatywne? Spójrz tylko na tę magię:

Magię, ponieważ funkcję (częściowo zaaplikowane mnożenie) podajemy tylko w drugim elemencie pierwszej kroki, a okazuje się, że pierwsze elementy obu krotek jakimś dziwnym sposobem dokonały swojej konkatenacji.

Skąd Haskell wiedział jaką funkcję ma wywołać dla argumentów "Hello" oraz "world"? Okazuje się, że do gry wkraczają tu monoidy, o czym mówi nam implementacja klasy Applicative dla krotek:

Oznaczana operatorem (,) krotka jest operatorem dwuargumentowym. Funktory aplikatywne (podobnie jak zwykłe funktory) możemy zaimplementować wyłącznie dla typów o jednym parametrze, a więc jeden z parametrów krotki (typ jej pierwszego elementu) został już częściowo zaaplikowany jako a – typ, który musi być monoidem (o tym co to są monoidy przeczytasz w artykule „Monoidy w Haskellu”).

Skoro monoid, to i operacja mappend. Dla list (pamiętajmy, że stringi to listy charów) jest ona zdefiniowana jako konkatencja (++). To właśnie dlatego funktor aplikatywny potrafi w pokazany powyżej sposób obsłużyć operacje na krotkach. Z tego też względu taki kod niestety nie zadziała:

Ale zakładany efekt osiągniemy, korzystając z klasy Product, która monoidem jest (w odróżnieniu od zwykłych intów):

Monoidalność

Zatrzymajmy się na chwilę przy monoidach. Niekiedy można spotkać się ze stwierdzeniem, że funktory aplikatywne są monoidalne. Bynajmniej nie oznacza to, że wszystkie instancje Applicative muszą być przy tym instancjami klasy Monoid (o klasie tej pisałem w artykule Półgrupy i monoidy w Haskellu). Owszem, niektóre typy implementują obie te klasy, ale nie wszystkie.

Nie chodzi tu bowiem o monoid na poziomie wartości (a takie są instancje klasy Monoid), ale na poziomie typów. Pamiętamy, że dwuargumentowe działanie zdefiniowane dla monoida operuje na dwóch monoidach, a rezultatem również jest monoid:

Czyż nie ma tu pewnego podobieństwa do definicji funkcji <*>?

Tak jak wartości a w funkcji mappend są różne, tak tutaj typ f posiada różne parametry. Mamy więc monoidalność, choć nie w identycznym sensie jak w przypadku instancji klasy Monoid.

Jeśli chcesz nieco lepiej poznać temat zależności między funktorami aplikatywnymi a monoidami, polecam poniższe video, w którym omawiana jest własnie ta kwestia:

ApplicativeDo

Jeśli widziałeś już trochę haskellowego kodu, to być może miałeś okazję zetknąć się z notacją do. Zazwyczaj pojawia się ona w towarzystwie monad (np. IO) i jest po prostu lukrem składniowym, który pozwala uczynić kod bardziej czytelnym, eliminując konieczność użycia różnych dziwnych operatorów. Nie będziemy teraz zajmować się szczegółami tej notacji – temat jest obszerny, więc z pewnością poświęcę mu osobny wpis.

Warto natomiast wiedzieć, że notacja do może być używana również dla typów, które nie są monadami, a jedynie funktorami aplikatywnymi. Mozliwość ta została wprowadzona w GHC 8.0.1 i aby jej używać należy odblokować rozszerzenie języka o nazwie ApplicativeDo. Przykładowo, mając taki kod, operujący na danych typu ZipList (używam tu właśnie tego typu, ponieważ jest on instancją Applicative, nie będąc przy tym instancją Monad, jak wiele popularnych typów) :

możemy zastąpić go taką oto składnią:

Po więcej szczegółów i technicznego mięska odsyłam do publikacji Desugaring Haskell’s do-Notation into Applicative Operations.

Bifunktory aplikatywne

Podobnie, jak oprócz zwykłych funktorów istnieją też bifunktory, tak również dla funktorów aplikatywnych istnieje ich dwuparametrowy odpowiednik w postaci bifunktorów aplikatywnych. W Haskellu są one reprezentowane przez klasę Biapplicative. Jej metody to bipurebiliftA2 oraz operatory:

  • <<*>> – bifunktorowy odpowiednik wyżej opisanego <*>
  • <<* – odpowiednik <*
  • *>> – odpowiednik *>

Między bifunktorem (Bifunctor) a bifunktorem aplikatywnym istnieje analogiczna zależność jak między funktorem, a funktorem aplikatywnym:

Samo zaś działanie bifunktorów aplikatywnych niechaj zobrazuje poniższy fragment kodu (z naszymi ulubionymi tuplami, a jakże by inaczej!):

Linki

.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *