Haskell i monady - czy monady są jeszcze egzotyczne?
Język programowania Haskell to ma straszną opinię. Że trzeba rozumieć co to teoria kategorii albo monady. Tylko że teoria kategorii odnosi się do wszystkich języków programowania. A monady są podobno możliwe nawet w Bashu. Gdy tak mówię lub piszę, ludzie mi nie wierzą. Ostatnio z tego powodu dostałem kod w Haskellu, który wyglądał mniej więcej tak:
import qualified Data.Map as Map
nestedMap = Map.fromList [("Scheme", Map.fromList [("OCaml", Map.fromList [("Haskell", "MonadsAreAwesome")])])]
main = do
print $ Map.lookup "Scheme" nestedMap >>= Map.lookup "OCaml" >>= Map.lookup "Haskell"
print $ Map.lookup "Scheme" nestedMap >>= Map.lookup "Racket" >>= Map.lookup "Haskell"
Po uruchomieniu dający na wyjściu:
Just "MonadsAreAwesome"
Nothing
Do kodu dostałem pytanie, czy można to łatwo napisać w Scali. Otóż można i to nie tylko w Scali, ale także w Javie.
Analiza przykładu w Haskellu
Ale najpierw przyjrzyjmy się kodowi w Haskellu. Za pomocą trzykrotnego wywołania funkcji fromList
o sygnaturze:
fromList :: Ord k => [(k, a)] -> Map k a
tworzymy trzykrotnie zagnieżdżoną mapę. Następnie za pomocą funkcji lookup
o sygnaturze:
lookup :: Ord k => k -> Map k a -> Maybe a
wyciągamy z mapy wartości. Jednak metoda ta nie zwraca gołych wartości, a wartości opakowane w Maybe
, które jest odpowiednikiem Option
ze Scali oraz Optional
z Javy. Konstrukcja Maybe
/Option
/Optional
jest prawdopodobnie najprostszą możliwą do implementacji monadą.
A czym jest monada? Zacznijmy od tego, że monada w Haskellu jest po prostu klasą typów rozszerzająca klasę typów Monad
:
class Monad m where
(>>=) :: m a -> ( a -> m b) -> m b
(>>) :: m a -> m b -> m b
return :: a -> m a
fail :: String -> m a
Oraz spełniająca pewne prawa monad, które jednak tym razem pominę.
Teraz wystarczy ustalić co to jest klasa typu. Cytując Programowanie Funkcyjne dla Śmiertelników ze Scalaz, Klasa typu (ang. type class) jest to cecha (ang. trait), która:
- nie ma wewnętrznego stanu
- ma parametr typu [generyk]
- ma przynajmniej jedną metodą abstrakcyjną (kombinator prymitywny (primitive combinator))
- może mieć metody uogólnione (kombinatory pochodne (derived combinators))
- może rozszerzać inne typeklasy [klasy typów]
W naszym przypadku parametrem typu jest a
. Ponieważ nie ma wypisanych żadnych ograniczeń co do a
, a
reprezentuje dowolny typ. W szczególnym przypadku inną monadę, dzięki czemu możemy zagnieżdżać monady różnych typów.
Spójrzmy jeszcze raz na deklarację monady w Haskellu:
class Monad m where
(>>=) :: m a -> ( a -> m b) -> m b
(>>) :: m a -> m b -> m b
return :: a -> m a
fail :: String -> m a
Mamy tu zadeklarowane dwa operatory i dwie metody:
- Operator
>>=
składa dwie monady w jedną. Nazywany jestbind
. W innych językach programowania jego odpowiednikiem często jest metodaflatMap
. Chyba że jak Groovy inspirują się SmallTakiem i wtedy jestcollectMany
. Ale wtedy to już nic nie pomoże. - Operator
>>
odrzuca pierwszy operand i zwraca drugi. Wydaje się to totalnie bezsensowne. Przynajmniej do czasu, gdy sobie nie uświadomimy, że w Haskellu nie ma klasycznego bloku kodu do wykonania i ten operator jest hakiem na to. - Metoda
return
zwana jest w innych językach programowania teżpure
,unit
lub po prostucreate
. W Scali można także spotkaćapply
co wynika z lukru składniowego tego języka programowania. Służy ona do tworzenia monady. - Metoda
fail
służy do tworzenia monady zawierającej błąd (ang. error).
Kod z Scalaz
Scalaz jest biblioteką, która dla języka Scala dodaje składnię z języka Haskell, czyli głównie wszelkiego rodzaju monady.
Ten sam kod napisany w Scali przy pomocy biblioteki Scalaz i operatora >>=
:
object ScalazMonad extends App {
import scala.collection.Map
import scalaz.Scalaz._
val nestedMap = Map("Scheme" -> Map("OCaml" -> Map("Haskell" -> "MonadsAreAwesome")))
println(nestedMap.get("Scheme") >>= (_.get("OCaml") >>= (_.get("Haskell"))))
println(nestedMap.get("Scheme") >>= (_.get("Racket") >>= (_.get("Haskell"))))
}
Po jego uruchomieniu na wyjściu dostaniemy:
Some(MonadsAreAwesome)
None
Kod w gołej Scali
Powyższy kod też można napisać w gołej Scali bez żadnych dodatkowych bibliotek:
object ScalaMonad extends App {
import scala.collection.Map
val nestedMap = Map("Scheme" -> Map("OCaml" -> Map("Haskell" -> "MonadsAreAwesome")))
println(nestedMap.get("Scheme") flatMap (_.get("OCaml") flatMap (_.get("Haskell"))))
println(nestedMap.get("Scheme") flatMap (_.get("Racket") flatMap (_.get("Haskell"))))
}
Zwróć uwagę, że zamiast operatora >>=
mamy tu metodę flatMap
.
Po uruchomieniu kodu na wyjściu dostaniemy:
Some(MonadsAreAwesome)
None
Wniosek? Kolekcje w Scali są monadami, ponieważ mają metodę flatMap
. Czyli jeśli używałeś kolekcji w Scali, to już używałeś monad w swoim życiu.
Kod z Vavr
Vavr to biblioteka, która do języka Java dodaje składnię języka Scala. Głównie kolekcje i kilka monad do opakowywania wartości (Option
, Either
, Try
) oraz interfejsy dla funkcji umożliwiające currying
.
Biblioteka Vavr dla Javy w wielu miejscach wygląda jak przepisana biblioteka kolekcji ze Scali. Tak więc nikogo nie powinno dziwić, że kod jest prawie identyczny, nie licząc oczywiście różnic wynikających z ograniczeń Javy:
import io.vavr.collection.*;
public class WriteOnly {
public static void main(String[] args){
final var nestedMap = HashMap.of("Scheme", HashMap.of("OCaml", HashMap.of("Haskell", "MonadsAreAwesome")));
System.out.println(nestedMap.get("Scheme")).flatMap(v1 -> v1.get("OCaml")).flatMap(v2 -> v2.get("Haskell"));
System.out.println(nestedMap.get("Scheme")).flatMap(v1 -> v1.get("Racket")).flatMap(v2 -> v2.get("Haskell"));
}
}
Po uruchomieniu kodu na wyjściu dostaniemy:
Some(MonadsAreAwesome)
None
Kod w gołej Javie
I wreszcie monada w gołej Javie:
import java.util.*;
public class WriteOnly {
public static void main(String[] args){
final var nestedMap = Map.of("Scheme", Map.of("OCaml", Map.of("Haskell", "MonadsAreAwesome")));
System.out.println(Optional.ofNullable(nestedMap.get("Scheme")).flatMap(v1 -> Optional.ofNullable(v1.get("OCaml")).flatMap(v2 -> Optional.ofNullable(v2.get("Haskell")))));
System.out.println(Optional.ofNullable(nestedMap.get("Scheme")).flatMap(v1 -> Optional.ofNullable(v1.get("Racket")).flatMap(v2 -> Optional.ofNullable(v2.get("Haskell")))));
}
}
Po uruchomieniu kodu na wyjściu dostaniemy:
Optional[MonadsAreAwesome]
Optional.empty
Tutaj kod jest najbardziej rozwlekły. Wynika to głównie z tego, że metoda Map::get
w Javie zwraca gołą wartość, a nie wartość opakowaną w Optional
. Na szczęście możemy sami opakować tę wartość, jednak wymaga to sporo kodu. Jeszcze dodatkowo jakiś geniusz wymyślił, że instancja klasy Optional
jest tworzona za pomocą metody Optional.ofNullable
. Bo metoda Optional.of
przyjmuje tylko wartości różne od wartości null
. Tak jakby był sens tworzyć instancje klasy Optional
dla wartości, która na pewno nie jest równa null
.
Z drugiej strony, dzięki temu, że metoda ofNullable
jest zadeklarowana tylko w klasie Optional
możemy użyć statycznego importu i skrócić kod
import java.util.*;
import static java.util.Optional.ofNullable;
public class WriteOnly {
public static void main(String[] args){
final var nestedMap = Map.of("Scheme", Map.of("OCaml", Map.of("Haskell", "someValue")));
System.out.println(ofNullable(nestedMap.get("Scheme")).flatMap(v1 -> ofNullable(v1.get("OCaml")).flatMap(v2 -> ofNullable(v2.get("Haskell")))));
System.out.println(ofNullable(nestedMap.get("Scheme")).flatMap(v1 -> ofNullable(v1.get("Racket)")).flatMap(v2 -> ofNullable(v2.get("Haskell")))));
}
}
Po uruchomieniu kodu na wyjściu dostaniemy:
Optional[MonadsAreAwesome]
Optional.empty
Haskell i notacja do
Ktoś pomoże powiedzieć, że te długie linie źle się czyta. Szczęśliwie w Haskellu jest notacja do
(ang. do notation), która pozwala zapisać ten kod w sposób bardziej czytelny:
import Data.Map.Strict as Map
nestedMap = Map.fromList [("Scheme", Map.fromList [("OCaml", Map.fromList [("Haskell", "MonadsAreAwesome")])])]
main = do
print $ do
scheme <- nestedMap !? "Scheme"
ocaml <- scheme !? "OCaml"
haskell <- ocaml !? "Haskell"
return haskell
print $ do
scheme <- nestedMap !? "Scheme"
ocaml <- scheme !? "Racket"
haskell <- ocaml !? "Haskell"
return haskell
- Po prawej stronie operatora
<-
mamy wyrażenie, które zwraca monadę - Po lewej stronie operatora
<-
mamy wartość wypakowaną z monady, którą możemy użyć do konstrukcji kolejnej monady. Czyli operator<-
jest tłumaczony na metodębind
. - słowo
return
nie jest słowem kluczowym Haskella, tylko metodą konstruującą monadę z wartości. Jest to metoda polimorficzna ze względu na typ zwracany. Rzecz nie do osiągnięcia w obiektowych językach programowania.
Scala i For comprehensions
I znów notacja do
nie jest niczym wyjątkowym, zarezerwowanym tylko dla Haskella. Jest dostępna np. w Scali jako for comprehensions:
object ForComprehensions extends App {
import scala.collection.Map
val nestedMap = Map("Scheme" -> Map("OCaml" -> Map("Haskell" -> "MonadsAreAwesome")))
println (
for {
scheme <- nestedMap get "Scheme"
ocaml <- scheme get "OCaml"
haskell <- ocaml get "Haskell"
} yield haskell
)
println (
for {
scheme <- nestedMap get "Scheme"
racket <- scheme get "Racket"
haskell <- racket get "Haskell"
} yield haskell
)
}
Tutaj podobnie:
- Po prawej stronie
<-
jest monada, chociaż w przypadku Scali wystarczy, że będzie to FlatMappable, czyli obiekt posiadający metodęflatMap
. - Po lewej stronie
<-
jest wartość wypakowana z monady. yield
jest słowem kluczowym Scali.
Ktoś może spytać skąd wyrażenie For Comprehensions
wie którą monade zwrócić? Otóż jest tu stosowana pewna sztuczka, że ostatnie użycie <-
jest zamieniane na metodą map
a nie flatMap
. Dlatego każda monada w Scali powinna także posiadać metodę map
.
Podsumowanie
Monady nie są niczym strasznym i możliwe, że używasz już ich na co dzień, nawet o tym nie wiedząc.