Monady i wyrażenia komputacyjne

W programowaniu funkcyjnym pracuje się czesto znacznie bliżej matematyki, a co za tym idzie pewne pojęcia brzmią bardzo skomplikowanie choć wcale takie być nie muszą. Dziś zobaczymy czym jest monada oraz jak F# ułatwia nam korzystanie z monad przez wyrażenia komputacyjne.

Monada to monoid z kategorii endofunktorów

Tak brzmi matematyczne określenie monady. Chociaż jak zacząłem się wczytywać w teorię kategorii, z której to określenie pochodzi, to przestałem rozumieć czym monady właściwie są.

Ale w skrócie:

  • endofunktor ~ funkcja 'a -> 'a
  • monoid - trójka (S, e, 𐌈) gdzie 𐌈: S * S -> S oraz ∀(a in S) e𐌈a = a𐌈e = a oraz zachodzi łączność.

Jak to się ma do monady? Ponieważ funkcje 'a -> 'a można ze sobą składać (𐌈) i mają identyczność fun x -> x to dostajemy prawie monoid. Jedyne czego nam brakuje to łączność, więc musimy ograniczyć nasze funkcje do takich gdzie kolejność ich aplikacji nie ma znaczenia. I tadaa - mamy monoid, który nazwiemy monadą.

Powrót do rzeczywistości

Ale tak naprawdę, z praktycznego punktu widzenia, monada jest trochę prostsza do zrozumienia. Zaprezentuję to na przykładzie:

type Error = 
    | InvalidOperation
    | ParsingError
    | Exception of System.Exception

type Result<'a> =
    | Success of 'a
    | Failure of Error

Naszym typem, który będzie określał wartość monadyczną jest Result<'a>. Określa on sukces wraz z pewną wartością oraz porażkę. Aby była to monada to potrzebujemy jeszcze dwóch operacji bind oraz return.

Operacja return ma sygnaturę 'a -> Result<'a> i jedynym jej zadaniem jest opakowanie naszej wartości w kontener jakim jest Result<'a>.

Operacja bind natomiast jest operacją łączącą wartość monadyczną z przekształceniem f: 'a -> Result<'b>.

Może czas na przykład implementacji, żeby lepiej to wchłonąć:

let ``return`` x = Success x

let bind result f =
    match result with
    | Success x -> f x
    | Failure err -> Failure err

Notka: w F# return jest słowem kluczowym, ale zawierając nazwę w `` możemy używać w środku czegokolwiek.

O ile return jest jasny, to popatrzmy na bind. Jeśli nasz result jest sukcesem to wyciągamy z niego wartość i aplikujemy funkcję f, która zwraca nam Result<'b>. Ogólnie 'a może się równać 'b w typie funkcji f (wyżej). A jeśli result był porażką, to nie bawimy się w aplikowanie funkcji tylko zwracamy tą porażkę.

Taki schemat działania pozwala nam na dość sensowne kontrolowanie błędów w naszej aplikacji. Zamiast rzucać wyjątkiem, zwracamy porażkę, która niesie informacje o błędzie.

W dodatku możemy właśnie łączyć kilka funkcji w łańcuch i jeśli w której kolwiek dojdzie do błędu to na niej się ten łańcuch zatrzyma. To daje nam bind.


Railway Oriented Programming
from F# for fun and profit

Za pomocą właśnie takich kontrukcji można uzyskać programowanie zorientowane na kolej. Mamy dwa tory: tor sukcesu i tor porażki. Jeśli na torze sukcesu dojdzie do błędu to zjeżdżamy na tor porażki i już z niego nie wracamy.

Przykład funkcji używającej tego schematu:

let myProcess state =
    validate state
    >=> update
    >=> send

Gdzie operator >=> to operacja bind.

Więcej na ten temat znajdziecie tu: Railway Oriented Programming.

Wyrażenia komputacyjne

W F# aby uprościć używanie monad, zostało dodane coś takiego jak Computation Expressions. W podlinkowanym artykule znajdziecie pełen zestaw funkcji jakie można wykorzystać, jednak ja przedstawię tylko te które są ważne dla naszego przykładu.

Wyrażenia komputacyjne możecie kojarzyć chociażby z monady Async<'T>, która dostarcza operacje asynchroniczne w F#. Wyrażenie komputacyjne umieszczamy wtedy w bloku async { }. Twórcy F# udostępniają nam interfejs do tworzenia naszych własnych wyrażeń.

Zaczniemy od definicji klasy ResultBuilder

type ResultBuilder() =
    member this.Return(x) = ``return`` x
    member this.ReturnFrom(result) = result
    member this.Bind(x, f) = bind x f

Ta klasa jest implementacją interfejsu wyrażenia komputacyjnego. Kolejnym naszym krokiem jest utworzenie jej instancji:

let result = ResultBuilder()

Możemy teraz używać bloków result { }, w taki sposób, że:

let a = result { return 1 }  // val a : Result<int> = Success 1
let b : Result<int> =
    result { return! Failure InvalidOperation }
                             // val b : Result<int> = Failure InvalidOperation
let c r = result {
    let! v = r
    if v = 1 then return 2
    else return 3
}
// c a = Success 2
// c b = Failure InvalidOperation
// c (c a) = Success 3
// c (c b) = Failure InvalidOperation

Ogólnie wygląda to tak:

  • { return x } - result.Return(x)
  • { return! x } - result.ReturnFrom(x)
  • { let! y = x in expr } - result.Bind(x, fun y -> expr)

Jaki jest cel tych wyrażeń komputacyjnych? Przede wszystkim uproszczenie czytelności kodu. Takie bloki czyta się łatwiej niż szeregi zagnieżdżonych funkcji.