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.
Spis treści
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:
1 2 3 |
class Functor f => Applicative f where pure :: a -> f a (<*>) :: f (a -> b) -> f a -> f b |
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
).
1 2 3 4 |
λ: pure 5 :: Maybe Int Just 5 λ: [(*2)] <*> pure 10 [20] |
<*>
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:
1 2 |
(<$>) :: (a -> b) -> f a -> f b (<*>) :: f (a -> b) -> f a -> f b |
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ć:
1 2 |
λ: (+2) <$> Just 5 Just 7 |
natomiast dla funktora aplikatywnego użyjemy np. takiego kodu:
1 2 |
λ: Just (+2) <*> Just 5 Just 7 |
lub też, wykorzystując poznaną chwilkę temu funkcję pure
:
1 2 |
λ: pure (+2) <*> Just 5 Just 7 |
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.:
1 |
f = fmap (+) (Just 10) |
…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:
1 2 3 4 5 6 7 |
λ: addThree x y z = x + y + z λ: pure addThree <*> Right 10 <*> Right 20 <*> Right 30 Right 60 λ: (+) <$> (Just 10) <*> (Just 5) Just 15 |
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ć:
1 |
pure f <*> (Just 5) <*> (Just 10) |
możemy skrócić to wyrażenie do:
1 |
liftA2 f (Just 5) (Just 10) |
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 *>
:
1 2 3 4 |
λ: (Just 10) *> (Just 20) Just 20 λ: (Just 10) <* (Just 20) Just 10 |
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:
1 2 |
(<*>) :: f (a -> b) -> f a -> f b (<**>) :: f a -> f (a -> b) -> f b |
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:
1 2 |
λ: (*10) <$> (3,4) (3,40) |
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
:
1 2 |
λ: bimap (*10) (*100) (3,4) (30,400) |
A co stanie się, jeśli potraktujemy krotki jako funktory aplikatywne? Spójrz tylko na tę magię:
1 2 |
λ: ("Hello ", (*100)) <*> ("world", 4) ("Hello world",400) |
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:
1 |
instance Monoid a => Applicative ((,) a) |
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:
1 |
((*10), (*100)) <*> (3, 4) |
Ale zakładany efekt osiągniemy, korzystając z klasy Product
, która monoidem jest (w odróżnieniu od zwykłych intów):
1 2 |
λ: (Product 10, (*100)) <*> (Product 3, 4) (Product {getProduct = 30},400) |
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:
1 |
mappend :: a -> a -> a |
Czyż nie ma tu pewnego podobieństwa do definicji funkcji <*>
?
1 |
(<*>) :: f (a -> b) -> f a -> f b |
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) :
1 |
(\x y z -> x+y*z) <$> ZipList [1,2] <*> ZipList [3,4] <*> ZipList [5,6] |
możemy zastąpić go taką oto składnią:
1 2 3 4 5 6 |
{-# LANGUAGE ApplicativeDo #-} do x <- ZipList [1,2]; y <- ZipList [3,4]; z <- ZipList [5,6]; return (x+y*z) |
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 bipure
i biliftA2
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:
1 |
class Bifunctor p => Biapplicative p where |
Samo zaś działanie bifunktorów aplikatywnych niechaj zobrazuje poniższy fragment kodu (z naszymi ulubionymi tuplami, a jakże by inaczej!):
1 2 3 4 |
λ: bipure (*2) (+3) <<*>> (3,4) (6,7) λ: bipure (*) (+) <<*>> (3,4) <<*>> (5,6) (15,10) |
Linki
- Control.Applicative – dokumentacja.
- Data.Biapplicative – dokumentacja.
- Applicatives are monoidal, J. Moronuki.
- Functional Pearl. Applicative programming with effects, C. McBride, R. Paterson.
- StackOverflow: Representation of tuple applicative.