
A monad is just a monoid in the category of endofunctors (...)
- Philip Wadler
To chyba najbardziej znany cytat ze świata programowania funkcyjnego, autorstwa Philipa Wadlera.
Tłumacząc to na język polski, nie brzmi wcale prościej:
Monada to po prostu monoida w kategorii endofunktorów (...)
Ale co to właściwie oznacza? Aby to zrozumieć, musimy najpierw przyjrzeć się kilku kluczowym pojęciom: monadom, monoidom, funktorom i endofunktorom.
Te abstrakcyjne koncepcje mogą wydawać się skomplikowane na pierwszy rzut oka, ale są niezwykle potężne i użyteczne w praktyce.
W tym artykule przyjrzymy się każdemu z tych pojęć, wyjaśniając je na prostych przykładach w języku F# i rozprawiając się z tą "wiedzą tajemną" raz na zawsze.
Kontynuując, Endofunktor to nic innego jak po prostu typ, który 'opakowuje' jakąś wartość, lecz nadal pozostaje w tej samej kategorii typów.
W przypadku języka F#, jest Nim każdy generyczny typ jak np. list czy własny Box<'T>:
type Box<'T> = Box of 'T'
let x = Box 666 // x: Box<int>
printfn "%A" x // Box 666
Idąc dalej, endofunktor to funktor, lecz który jak już wspomniałem - działa w obrębie jednej kategorii, np. funkcja SaleTransaction -> Sale Transaction.
By łatwiej to zobrazować, przyjmijmy, że mamy poniższy typ reprezentujący transakcję:
type SaleTransaction = {
Id: int
Amount: decimal
Currency: string
IsRefunded: bool
}
Dla którego mamy poniższą funkcję:
let applyDiscount percentage transaction = // decimal -> SaleTransaction -> SaleTransaction
let discountAmount = transaction.Amount * (percentage / 100M)
{ transaction with
Amount = transaction.Amount - discountAmount }
I transakcję:
let transaction1 = // SaleTransaction
{ Id = 1
Amount = 100.0M
Currency = "USD"
IsRefunded = false }
let discountedTransaction = applyDiscount 13m transaction1 // SaleTransaction
printfn "Original transaction: \n\n %A" transaction1
printfn "Discounted transaction: \n\n %A" discountedTransaction
Której wynikiem jest:
Original transaction:
{ Id = 1
Amount = 100.0M
Currency = "USD"
IsRefunded = false }
Discounted transaction:
{ Id = 1
Amount = 87.000M
Currency = "USD"
IsRefunded = false }
Tym samym, applyDiscount jest endofunktorem, gdyż przyjmuje on SaleTransaction i zwraca także SaleTransaction.
No dobrze, więc skoro endofunktor to funktor, ale w obrębie jednej kategorii, to czym jest sam funktor?
A no nie zgadniecie?! Funktor to endofunktor z umiejętnością mapowania funkcji przez strukturę.

Generalizując, funktory to opakowania, które umieją się mapować np. List<SaleTransaction> albo Async<SaleTransaction>.
Dla przykładu, do istniejącej już pojedyńczej transakcji, dodajmy jeszcze dwie:
let transaction2 =
{ Id = 2
Amount = 200.0M
Currency = "USD"
IsRefunded = false }
let transaction3 =
{ Id = 3
Amount = 300.0M
Currency = "USD"
IsRefunded = false }
Następnie, posiadając funkcję:
let mapTransaction f transactionList = // ('a -> 'b) -> list<'a> -> list<'b>
List.map f transactionList
Możemy zmapować dane:
let discountedTransactions = // list<SaleTransaction>
mapTransaction (applyDiscount 10m) [ transaction1; transaction2; transaction3 ]
printfn "Discounted transactions: \n\n %A" discountedTransactions
Otrzymując wynik:
Discounted transactions:
[{ Id = 1
Amount = 90.00M
Currency = "USD"
IsRefunded = false }; { Id = 2
Amount = 180.00M
Currency = "USD"
IsRefunded = false }; { Id = 3
Amount = 270.00M
Currency = "USD"
IsRefunded = false }]
Innymi słowy, mapTransaction to funktor, gdyż pozwala on podnieść funkcję SaleTransaction -> SaleTransaction do poziomu list<SaleTransaction>.
Tym samym, skoro już rozumiemy, że:
Możemy ze świętym spokojem przejść do kolejnej kwestii jaką jest Monoida (huh ☠️).
W skrócie: Monoida tudzież monoid to struktura z operacją łączenia (append) oraz elementem neutralnym (identity).
Na przykładzie retail'u, może to być suma transakcji:
let combinedTransactions tx1 tx2 =
{ Id = 0
Amount = tx1.Amount + tx2.Amount
Currency = tx1.Currency
IsRefunded = tx1.IsRefunded && tx2.IsRefunded }
let emptyTransaction =
{ Id = 0
Amount = 0.0M
Currency = "USD"
IsRefunded = false }
let msg =
[ transaction1; transaction2; transaction3 ]
|> List.fold combinedTransactions emptyTransaction
printfn "Combined transaction: \n\n %A" msg
Wynik:
Combined transaction:
{ Id = 0
Amount = 600.0M // 100 + 200 + 300 = 600
Currency = "USD"
IsRefunded = false }
W takim przypadku, combineTransactions to operacja monoidalna, a emptyTransaction to element neutralny.
Dla lepszego zobrazowania może służyć ten bardziej trywialny przykład:
let strEmpty = "" // string
let strAppend a b = a + b // string -> string -> string
let msgx = ["A"; "B"; "C"] |> List.fold strAppend strEmpty // string
Lub bardziej magazynowy view:
type Inventory = (string * int) list
let emptyInventory: Inventory = [] // Inventory
let appendInventory (item1: Inventory) (item2: Inventory) =
item1 @ item2 // Inventory -> Inventory -> list<string * int>
let stock1 = [ ("item1", 10); ("item2", 5) ] // list<string * int>
let stock2 = [ ("item3", 7); ("item4", 3) ] // list<string * int>
let warehouse =
emptyInventory
|> appendInventory stock1
|> appendInventory stock2 // list<string * int>
printfn "Combined inventory: \n\n %A" warehouse
Wynik:
Combined inventory:
[("item3", 7); ("item4", 3); ("item1", 10); ("item2", 5)]
Na monoidy można patrzeć przez pryzmat zbioru zapasów z pustym magazynem i regułą dokupowania, w którego przypadku kolejność ta nie ma znaczenia.
Tajemnicza Monada tudzież monad to struktura (opakowanie) jak funktor z dodatkowymi regułamy sekwencyjnego działania. Innymi słowy: monada pozwala na łączenie operacji z kontekstem.
Jest to ważny element tzw Railway Oriented Programming, o którym więcej możecie przeczytać u Scotta Wlaschin'a: tutaj.
W tym przypadku z pomocą przychodzi typowy pattern matching, który pozwala Nam na odrzucanie niechcianych wartości.
Dla przykładu weźmy walidowanie zwrotów i np. waluty:
let validateCurrency transaction =
match transaction.Currency with
| "USD"
| "EUR"
| "GBP" -> Ok transaction
| _ -> Error(
sprintf "Unsupported currency: %s in transaction with id: %A" transaction.Currency transaction.Id
)
let refund transaction =
if transaction.IsRefunded then
Error(
sprintf "Transaction with id: %A already refunded." transaction.Id
)
else
Ok { transaction with IsRefunded = true }
Następnie tworzymy funkcję do procesowania transakcji, która binduje Nam zarówno validateCurrency jak i refund:
let processTransaction transaction = // SaleTransaction -> Result<SaleTransaction,string>
validateCurrency transaction |> Result.bind refund
Dla dobrobytu tworzymy kilka transakcji na potrzeby ewaluacji:
let goodTransaction = // SaleTransaction
{ Id = 1
Amount = 100.0M
Currency = "USD"
IsRefunded = false }
let badCurrencyTransaction = // SaleTransaction
{ Id = 2
Amount = 100.0M
Currency = "AUD"
IsRefunded = false }
let isRefundedTransaction = // SaleTransaction
{ Id = 3
Amount = 100.0M
Currency = "USD"
IsRefunded = true }
Finalnie, całość możemy zwalidować korzystając już z wcześniej utworzonej funkcji mapTransaction:
let validateTransactions =
mapTransaction processTransaction [ goodTransaction; badCurrencyTransaction; isRefundedTransaction ]
validateTransactions |> printfn "Validated transactions: \n\n %A"
Wynik:
Validated transactions:
[Ok { Id = 1
Amount = 100.0M
Currency = "USD"
IsRefunded = true };
Error "Unsupported currency: AUD in transaction with id: 2";
Error "Transaction with id: 3 already refunded."]
Tym samym, Result.bind to operacja monadyczna - tj. taka, która pozwala łączyć funkcje SaleTransaction -> Result<SaleTransaction, string>.
A sama monada to funktor posiadający reguły sekwencyjnego działania (bind / let! i return), pozwalający łaczyć kroki, nawet te które mogą się nie udać.
Podsumowując w sposób prosty:
Endofunktor to funktor, ale mający opakowanie wartości (Box<'T>, option, list).Funktor to endofunktor z funkcją map do przekształcania zawartości.Monoid to typ z pustym elementem i łączem, spełniający prawo łączności.Monada to funktor z dodatkowymi operacjami bind (let!) i return, umożliwiająca sekwencyjne łączenie obliczeń.Natomiast patrząc na to bardziej matematycznie możemy to podsumować, tak:
Endofunktor to funktor F: C -> C, który działa w obrębie jednej kategorii zachowując przy tym strukturę morfizmów.Funktor to homomorfizm kategorii F: C -> D, który odwzorowuje obiekty i morfizmy zachowując ich identyczność oraz kompozycję.Monoida to obiekt (M, *, e) z operacją łączną i neutralnym elementem e, który spełnia łączność oraz tożsamość.Monada to endofunktor T: C -> C posiadający transformacje Id => T i T2 => T, które spełniają aksjomaty jedności i łączności.Jak widzicie nie było raczej tak źle jak wydawać by się mogło, a i sam zapewne raz, a dobrze finalnie rozprawiłem się z powyższą wiedzą tajemną, w końcu zapamietując definicje.
Drugą wypadkową tego post'u zapewne będzie to, że zacznę pisać w F#, który zwyczajnie mi się spodobał - a jego zgodność z ekosystemem .NET jest kolejnym mocnym argumentem.