Grokaem monads

Note. translator: This is a translation of the first articles from a whole series of posts “Groaming Functional Programming” by Matt Tronton. Yes, this is another article about monads. But it’s different from anything I’ve read on the subject before. Therefore, I wanted to translate it in order to carefully read it myself, to share it with those who find it difficult to perceive such material in English or who did not catch their eye, in order, in the end, to simply save it, since the network is unreliable.

The most common way to explain the monad is to go through category theory. Knowing that a monad is a monoid in the category of endofunctors is both exciting and useful for general development, but of little help in a practical sense. The second, equally popular technique is to resort to the help of images, and now we are already adding the values ​​​​in boxes and getting them out of there (or, in general, a nightmare, rolling on the railroad). I do not argue that images are a good way to look at a phenomenon, but here we have equally moved away from category theory and practice. Comments under such articles do not cease to be filled with questions: what does this give us, did we live without a monad somehow?

The author, like a lecturer in mathematics, who lost the proof of the theorem and proved it right at the blackboard, focusing on an intuitive idea of ​​how one would like to solve the problem, gradually makes his way from a clumsy piece of code to a concise one.

Even if the monad did not exist, we would have to invent it, because it allows us to write cleaner code.

In this post, we will try to understand what is Монада personally reinventing it on a working example.

Small example in F#

We will use the language F#, but even if you have not used it before, it will not be difficult for you to figure it out. All you need is to learn the following minimum:

  • F# has a type option. It represents either the presence of some value (Some), or its absence through the value None. This type is usually used instead of null to indicate the absence of a value.

  • Pattern matching for a type option as follows:

    match anOptionalValue with
    | Some x -> // выражение на случай, если значение существует
    | None -> // выражение, если значение отсутствует
  • F# has a pipeline statement that is written like this: |> (to be quite precise, this is a direct pipeline operator – forward pipe operator approx. translator). It is an infix operator, meaning it applies the value on its left to the function on its right. For example, if the function toLower takes a string and converts it to lowercase, then the expression "ABC" |> toLower will return "abc".

Test Scenario

Let’s say we’re writing code that needs to deduct money from a user’s credit card. If the user exists and has a credit card saved in their profile, we can charge the funds, otherwise we will have to signal that nothing happened.

Our data model is F#

type CreditCard =
    { Number: string
      Expiry: string
      Cvv: string }

type User =
    { Id: UserId
      CreditCard: CreditCard option }

Please note that the field CreditCard type User noted as optionbecause the card may not be specified.

Our first implementation

Let’s try to implement the function chargeUserCard. First, we’ll define a couple of helper functions that we’ll use as stubs for finding a user and debiting funds.

let chargeCard (amount: double) (card: CreditCard): TransactionId option =
    // синхронно списывает средства с карты и возвращает
    // некий Id транзакции в случае успеха, иначе возвращает None

let lookupUser (userId: UserId): User option =
    // синхронно ищет пользователя, которого может и не быть

let chargeUserCard (amount: double) (userId: UserId): TransactionId option =
    let user = lookupUser userId
    match user with
    | Some u ->
        match u.CreditCard with
        | Some cc -> chargeCard amount cc
        | None -> None
    | None -> None

Ready! But it turned out pretty messy. Double pattern matching is not the clearest code to read. Yes, in this simple example, we could leave it like that, but what if we had a third or fourth level of nesting?

We could solve this with a few feature extractions, but there is another problem. Note that in both cases, when matching against the value None returns None. In our example, this doesn’t look scary because the default value is simple and only repeats twice. But we should definitely be capable of better than that.

What we really want is to be able to say “If at some point we can’t continue because some data is missing, then stop and return None.”

Our desired realization

Let’s imagine for a moment that the data is always present and we don’t have values ​​like option. Let’s call this function chargeUserCardSafe and it should look something like this:

let chargeUserCardSafe (amount: double) (userId: UserId): TransactionId =
    let user = lookupUser userId
    let creditCard = u.CreditCard
    chargeCard amount creditCard

Note that the function now returns just TransactionId instead of optionbecause it cannot fail.

It would be great if we could write code that looks exactly the same, even though the data may sometimes be missing. For this to work, we must put something between these lines of code so that the types match and connect them in this way.

Refactoring for a cleaner implementation

How is this connecting element supposed to work? It should complete the calculation if in the previous step we got Noneotherwise it must extract the value from Some and pass it to the next line. In essence, this is the pattern matching we wrote about above.

Well, let’s see if we can extract it. First, let’s rewrite our function, which always executes correctly, as a pipeline so that we can easily inject our new function between computation steps later.

// Эта вспомогательная функция нужна лишь для того, 
// чтобы нам было проще объединить все шаги
let getCreditCard (user: User): CreditCard option =

let chargeUserCardSafe (amount: double) (userId: UserId): TransactionId =
    |> lookupUser
    |> getCreditCard
    |> chargeCard amount

All we have done is turn our calculation into a pipeline with a special operator. Now if the functions lookupUser and chargeCard will return again option, this example will no longer compile. The problem is that we cannot write

userId |> lookupUser |> getCreditCard

because lookupUser returns type User optionand we are trying to pass this result to the input of a function that takes User.

So we have two ways to fix this error.

  1. Write a function like User option -> Userwhich will expand the value optionso that it can be passed down the pipeline. However, ignoring the value None, we lose information about the possible lack of data. In imperative programming, this is solved by throwing an exception. But, functional programming is supposed to keep us safe, so we won’t do that.

  2. Instead, we can change the function on the right side of the operator so that it can take the type User optionnot just User. We need something that takes a function as input and converts it to another function.

We know that this high order function must be of type

(User -> CreditCard option) -> (User option -> CreditCard option)

Let’s write it simply respecting the types. We’ll call her liftGetCreditCardbecause it “raises” the function getCreditCard to work with input data like option.

let liftGetCreditCard getCreditCard (user: User option): CreditCard option =
    match user with
    | Some u -> u |> getCreditCard
    | None -> None

Excellent! We are approaching our ideal function chargeUserCard. Now our code looks like this

let chargeUserCard (amount: double) (userId: UserId): TransactionId option =
    |> lookupUser
    |> liftGetCreditCard getCreditCard
    |> chargeCard double

Partially applying getCreditCard to liftGetCreditCardwe have created a function with the signature User option -> CreditCard optionwhich is what we wanted to achieve.

Actually not really. Now we have the same problem, only further down the call chain. Function chargeCard accepts CreditCardand we are trying to convey to her CreditCard option. No problem, just apply the same trick again.

let liftGetCreditCard getCreditCard (user: User option): CreditCard option =
    match user with
    | Some u -> u |> getCreditCard
    | None -> None

let liftChargeCard chargeCard (card: CreditCard option): TransactionId option =
    match card with
    | Some cc -> cc |> chargeCard
    | None -> None

let chargeUserCard (amount: double) (userId: UserId): TransactionId option =
    |> lookupUser
    |> liftGetCreditCard getCreditCard
    |> liftChargeCard (chargeCard amount)

On the threshold of discovery

Have you noticed how similar these two functions are? lift..., how are they not very dependent on the type of the first argument? Basically, it’s just a function of the value contained inside optionto another option value. Let’s see if we can write a single function that handles all options. We can do this by renaming the first argument to f (for a function) and removing most of the type hints because F# will infer the generic types for us.

let lift f x =
    match x with
    | Some y -> y |> f
    | None -> None

The type that F# inferred for the function lift has the form

('a -> 'b option) -> ('a option -> 'b option)

where 'a and 'b – generic types. It may seem that this signature is rather verbose and abstract, but let’s put it next to the more specific signature of our function. liftGetCreditCard.

(User -> CreditCard option) -> (User option -> CreditCard option)

('a -> 'b option) -> ('a option -> 'b option`)

specific type User has been replaced by a generic 'aand the concrete type CreditCard per type 'b. This happened because the functions lift no matter what is inside the container option, it just says “give me some function ‘f’ and I’ll apply it to the value contained in ‘x’ if that value exists.” The only limitation is that the function f accepts the type that is inside option.

Okay, now we can refactor our function a little more. chargeUserCard.

let chargeUserCard (amount: double) (userId: UserId): TransactionId option =
    |> lookupUser
    |> lift getCreditCard
    |> lift (chargeCard amount)

Now it really doesn’t look like the typeless version option. However, let’s do the final touch and rename liftin andThen, because intuitively, we can think of this function as a continuation of the calculation in the presence of data. Thus, we can say, “Do something, and then, if possible, do something else.”

Our code is quite easy to read and reflects well how we think about solving a similar problem. We look up the user, then if it exists, we get their credit card information, and finally, if it exists, we charge the card.

Congratulations! You have just discovered the Monad

Function lift / andThenwhich we wrote, is what makes the values option Monad. Usually, when talking about Monads, such a function is called binding (bind), but this is not so important for understanding monads. What matters is that you can see why we wrote this function and how it works. Essentially a Monad is a class of things with a certain “then-able” functionality. (I did not pick up a similar short term in Russian, the author means that a certain continuation operation can be performed on the monad, which takes into account the result of the previous step. Cleverly, a monad is an abstraction of a linear chain of related computations. approx. translator)

Hey, I recognize you!

There is another reason why I renamed lift in andThen. If you develop in JavaScript, everything we have done may seem like Promise with method then. In this case, you’ve probably already dealt with Monads. Promise this is also a monadin fact, I, unlike the author, belong to the camp of those who do not consider Promise to be a monad. Homework – google why. approx. translator). Exactly the same as with optionit has a method thenwhich takes another function as input and calls it on the result of the instance Promiseif it completed successfully.

Monads are just “then-able” containers

Another good way to intuitively understand Monads is to think of them as value containers.nevertheless, here the author could not resist the containers approx. translator). option it is a container that either contains a value or is empty. Promise it is a container that “promises” to store the value of some asynchronous computation if it succeeds.

Of course, there are other Monads, such as List, which contains the values ​​of many calculations, and ResultA that contains the value if the calculation succeeded or an error if it didn’t. For each of these containers, we can define a function andThenwhich defines how to apply a function that takes an object inside the container to the object wrapped in the container.

Monads in the wild

We learned that Monads are just container types with a “then-able” function, usually called bind. We can use this function to chain calculations that take simple values ​​as input and return wrapped values ​​of a different type.

Monads are useful because they are applicable to a large number of tasks. There are many types where we can get rid of boilerplate code by defining a method like this bind. A monad is just a name given to such a type, and as Richard Feynman said, names do not constitute knowledge.

In the next series

If you remember, the original goal that we set for ourselves when we started our refactoring was to write code like this

let chargeUserCard (amount: double) (userId: UserId): TransactionId option =
    let user = lookupUser userId
    let creditCard = u.CreditCard
    chargeCard amount creditCard

But we still have to deal with the meanings option. That is, we have not reached our goal completely. In the next post, we’ll see how computation expressions can be used in F# to achieve a more imperative style even when working with Monads.

Similar Posts

Leave a Reply