Haskell i monady - czy monady są jeszcze egzotyczne?

13 minut(y)

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 jest bind. W innych językach programowania jego odpowiednikiem często jest metoda flatMap. Chyba że jak Groovy inspirują się SmallTakiem i wtedy jest collectMany. 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 prostu create. 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.