Funktor, Monada i Aplikatywa w Haskellu
Trzy klasy typów Funktor, Monada i Aplikatywa są to prawdopodobnie trzy najpopularniejsze klasy typów do przetwarzania danych.
Funktor (ang. Functor)
Funktor w Haskellu jest prawdopodobnie najprostszą klasą typu do przetwarzania danych.
Podstawą Funktora jest metoda fmap
:
fmap :: (a -> b) -> (f a -> f b)
fmap
może wydawać się bezsensowny, ponieważ pobiera funkcję i zwraca funkcję. Jeśli jednak zapiszemy tę sygnaturę inaczej, to nabierze to większego sensu:
fmap :: (a -> b) -> f a -> f b
(<$>) :: (a -> b) -> f a -> f b
(<&>) :: f a -> (a -> b) -> f b
Teraz:
- Funkcja
fmap
pobiera dwa argumenty, funkcję mapującą i funktor do przemapowania. - Operator
(<$>)
podobnie jak operator($)
pozwala pomijać nawiasy. - Operator
(<&>)
podobnie jak operator(&)
pozwala pisać kod w stylu bardziej obiektowym.
Przykłady użycia:
fmap (function1 data1)
fmap $ function1 data1
function1 <$> data1
data1 <&> function1
W moim parserze języka ETA preferuje operator (<$>)
:
unescapedStringParser :: Parser Instruction
unescapedStringParser = U <$> stringParser
labelDefinitionParser :: Parser Instruction
labelDefinitionParser = L <$> (char '>' *> identifierParser <* char ':')
includeFileParser :: Parser Instruction
includeFileParser = D <$> (char '*' *> fileNameParser <* char '\n')
Są jeszcze dwa pomocnicze operatory różniące się kolejnością argumentów:
($>) :: f a -> b -> f b
(<$) :: a -> f b -> f a
Przykład użycia:
>>> Nothing $> "Haskell"
Nothing
>>> Just "Scala" $> "Haskell"
Just "haskell"
>>> "Haskell" <$ Nothing
Nothing
>>> "Haskell" <$ Just "Scala"
Just "Haskell"
W moim parserze języka ETA preferuje operator (<$)
:
zeroOperandInstructionParser :: Parser Instruction
zeroOperandInstructionParser =
zeroOperandInstruction E ["E", "dividE"]
<|> zeroOperandInstruction T ["T", "Transfer"]
<|> zeroOperandInstruction A ["A", "Address"]
<|> zeroOperandInstruction O ["O", "Output"]
<|> zeroOperandInstruction I ["I", "Input"]
<|> zeroOperandInstruction S ["S", "Subtract"]
<|> zeroOperandInstruction H ["H", "Halibut"]
where zeroOperandInstruction i ts = i <$ (asciiCIChoices ts *> endWordParser)
Monada (ang. Monad)
Monada w Haskellu jest bazą dla całej rodziny klas typów. To właśnie nią straszy się niegrzeczne dzieci. W praktyce Monada jest bardzo prosta i sprowadza się do zdefiniowania jednego operatora, który występuje w dwóch wersjach:
(>>=) :: m a -> (a -> m b) -> m b
(=<<) :: (a -> m b) -> m a -> m b
Pozwalają one na łączenie dwóch monad w jedną monadę.
Tu można się zdziwić, bo dokumentacja Haskella preferuje operator (>>=)
. Mnie jednak bardziej pasuje operator (=<<)
, ponieważ pozwala czytać kod od prawej do lewej, podobnie jak operatory ($)
i (<$>)
.
Przykład z asemblera języka ETA wygląda następująco:
replaceStrings :: InstructionList -> InstructionList
replaceStrings il = replaceString =<< il
replaceString :: Instruction -> InstructionList
Aplikatywa (ang. Applicative)
Aplikatywa w Haskellu jest klasą pośrednią między Funktorem a Monadą.
Aplikatywa dziedziczy z Funktora, a Monada dziedziczy z Aplikatywy. W zasadzie jest to Monada bez operatorów (>>=)
i (=<<)
, czyli bez możliwości składania.
Aplikatywa posiada następujące operacje:
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b
(<**>) :: f a -> f (a -> b) -> f b
Gdzie:
- Funkcja
pure
pozwala utworzyć Aplikatywę. - Operator
(<*>)
jest bardzo podobny do(<$>)
, ale tutaj także funkcja jest opakowana w kontekst. - Operator
(<**>)
podobnie jak operator(<&)
pozwala na zapis w stylu bardziej obiektowym.
Są jeszcze dwa operatory pomocnicze:
(*>) :: f a -> f b -> f b
(<*) :: f a -> f b -> f a
Gdzie:
- Operator
(*>)
pozwala odrzucić pierwszy argument. - Operator
(<*)
pozwala odrzucić drugi argument.
Tu kolejność jest ważna. Pierwszy ignoruje pierwszą wartość. Drugi operator ignoruje drugą wartość.
W przeciwieństwie do poprzednich par operatorów, oba te operatory są potrzebne.
Przykład z asemblera języka ETA wygląda następująco:
labelDefinitionParser :: Parser Instruction
labelDefinitionParser = L <$> (char '>' *> identifierParser <* char ':')
includeFileParser :: Parser Instruction
includeFileParser = D <$> (char '*' *> fileNameParser <* char '\n')
Problemy z Aplikatywą
Nie zawsze było tak, że Aplikatywa był klasą bazową dla Monady. Aplikatywa została dodany później w związku z pracami nad parserami w Haskell.
Z tego powodu niektóre operacje z Aplikatywy są zdupkiowane w Monadzie. Są to:
pure = return
(<*>) = ap
(>>) = (*>)
Zawsze należy dbać o to, żeby operacje te posiadały te same implementacje.
Szczegóły implementacyjne w standardowej bibliotece Haskella
Nie wszystkie podane przeze mnie powyżej metody są zdefiniowane w klasach typów.
- Funktor posiada tylko metody
fmap
i(<$)
, przy czym do minimalnej definicji wystarczy tylko implementacjafmap
.(<$>)
i($>)
są funkcjami przyjmującymi Funktor. - Aplikatywa posiada tylko metody
pure
,(<*>)
,lift
,(*>)
oraz(*>)
. przy czym do minimalnej definicji wystarczy tylko implementacjapure
oraz(<*>)
lubliftA2
.(<**>)
jest dostarczana jako funkcja przyjmująca Aplikatywę. - Monad posiada tylko metody
(>>=)
,(>>)
,return
ifail
, przy czym do minimalnej definicji wystarczy tylko implementacja(>>=)
.(=<<)
,ap
i wiele innych są dostarczane jako funkcje przyjmujące Monadę
Inne problemy i inne implementacje
Nie tylko hierarchia dziedziczenia między Aplikatywą a Monadą jest zepsuta w Haskellu. Ogólnie cała hierarchia dziedziczenia jest popsuta (nie zawiera odpowiednio dużo kroków pośrednich). Na szczęście jest biblioteka semigroupoids, która rozwiązuje ten problem. Implementuje ona wszystkie teoretycznie istniejące kroki pośrednie:
Foldable ----> Traversable <--- Functor ------> Alt ---------> Plus Semigroupoid
| | | | |
v v v v v
Foldable1 ---> Traversable1 Apply --------> Applicative -> Alternative Category
| | | |
v v v v
Bind ---------> Monad -------> MonadPlus Arrow
Jest tylko jeden problem. Posiada ona własną definicję Funktora.
Podsumowanie
Haskell jest wspaniałym językiem programowania, ale niestety nie wszystko w nim jest idealne. Zmiany są jednak wprowadzane stopniowo, nawet jeśli wiąże się to ze złamaniem kompatybilności.