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.