What's a monad?

Note: The following explanation is based on this post by Bartosz Milewski.

Introduction

Monads are famously misunderstood. They've been likened to space suits, toxic waste and who knows what else. Depending on who you ask you might be told that monads are about:

Order of evaluation
Since languages that provide monads by default tend to be pure functional and declarative, we don't usually know the exact order of evaluation. Whatever else they may do, the main thing we use monads for, the story goes, is to enforce a particular order of evaluation in places where that is important.
Side effects and IO
Pure functional languages ban side effects so handling things like mutable state, errors and IO don't work the way most of us are used to. Monads allow us to do side effect-like things in a pure functional context.

Monads are used for these things but that's not really what they are or what they're about and focusing too much on those aspects tends to confuse the situation. What monads actually are is deceptively simple: monads are function composition.

A simple logging example

At least as far as programmers need to worry about them, monads are basically just a type of function composition. If you understand how function composition works, you pretty much understand monads.

So let's see how this works. For the purposes of explanation we need a way to compose functions. Pymonad has a Compose construct for this purpose but it's built on top of the Reader monad and I don't want to use a monad to explain monads. So we'll use this instead:

def compose(f, g):
    return lambda x: g(f(x))

Many languages that have a composition operator compose functions right-to-left but for a named composition function I prefer the semantics of composing left-to-right, so the above function evaluates f first and then feeds the result to g.

Alright, so suppose we're writing some program and we have these two functions:

def add_7(x):
    return x + 7

def mul_5(x):
    return x * 5

And somewhere in our program we commpose those two functions like so:

composed_arithmetic = compose(add_7, mul_5)
# ... more code ...
some_var = composed_arithmetic(6) # result: 65

So far so good. The problem comes when we decide it would be great if our functions created a log. We don't want to mutate global state and it's not really the job of our functions to know anything about the entire log so we just have our functions return a string along with the result that we were returning before.

def add_7(x):
    return x + 7, 'Adding 7 to input {}\n'.format(x)

def mul_5(x):
    return x * 5, 'Multiplying input {} by 5\n'.format(x)

But as soon as we do that, we've broken composed_arithmetic. add_7 is returning a tuple but mul_5 is expecting just a number. Importantly: We still want to be able to compose these functions like we did before; the overall behaviour of the system should be the same just with added logging; we need to do it in a way that deals with the logging information. So what do we do? We change how composition works!

def compose_with_logging(f, g):
    def _compose_internal(x):
        fx, f_log = f(x)
        gx, g_log = g(fx)
        return gx, f_log + g_log
    return _compose_internal

And then we update the composition:

composed_arithmetic = compose_with_logging(add_7, mul_5)
# ... more code ...

# This doesn't change!
some_var = composed_arithmetic(6) # result: 65, "Adding 7 to input 6
                                  #              Multiplying input 13 by 5"

Now you can add logging to any functions you want and if you were composing those functions all you need to do is switch to using compose_with_logging and everything will work as expected.

If we must use metaphores when talking about monads then we should at least use one that's relevant to programming. The functions above return a value that we're actually interested in and some additional metadata. In this case the metadata is logging information, in other cases it might be error states, mutable state, or something else. But in all cases adding metadata to the return types of our functions breaks composition forcing us to redefine composition in a way that takes the metadata into account.

That's a monad.

All monad types are tuples in disguise

The above example is a simplified version of the Writer monad. In a sense, the monad's type is the tuple returned from each of the functions: (int, string).

It turns out that all monads can actually be expressed in the general form (a, m), where a is the return value that you're interested in and m is the 'metadata' which the monad deals with. If you've played around with monads before though you'll know that they don't always look like tuples. So what's going on?

The answer has to do with algebraic data types and how they can be manipulated into equivalent forms. That's beyond the scope of this article, but a quick example should give you a general idea.

Let's look at simple error handling. Our metadata will be booleans: True indicates that the returned value is valid and can be used in further computations; False indicates the value is invalid and should be ignored.

def add_7(x):
    # Errors won't happen in this function so we can always return a
    # valid result.
    return (x + 7, True)

def not_zero(x):
    if x == 0:
        # It's an error if we get the value zero
        return (None, False)
    else:
        # But any other number is OK, we just pass it on.
        return (x, True)

def compose_with_errors(f, g):
    def _compose_internal(x):
        fx, f_err = f(x)
        gx, g_err = g(fx)
        return (gx, f_err and g_err)
    return _compose_internal

This works much like the logging example but instead of concatenating logs we use a logical and to combine error states. If either input function fails then the whole thing fails.

But having to construct tuples all the time is error prone and has little semantic value. Instead we can implement functions to do it for us.

def Result(x):
    return (x, True)

Error = (None, False)

Since the semantics of errors is that we ignore the returned value, one Error is just as good as any other and the value doesn't matter. That's why we don't need an Error function but can just use a constant. Now we can write our functions like so:

def add_7(x):
    # Errors won't happen in this function so we can always return a
    # valid result.
    return Result(x + 7)

def not_zero(x):
    if x == 0:
        # It's an error if we get the value zero
        return Error
    else:
        # But any other number is OK, we just pass it on.
        return Result(x)

The compose_with_errors function still works exactly the same way. So even though in this version the monad values don't look like tuples, they really are in the background. The translation to and from a tuple representation isn't always this straight-forward but it is always possible.

Generalizing

If you know a little bit about monads already you might be wondering where bind (the >>= operator in Haskell) is in all of this. It's actually hiding in plain sight.

When we wrote the logging example we had to write a compose_with_logging function. When we wrote the error example we needed a compose_with_errors function. And any other monads we wanted to implement would require their own compose_with_* function as well.

If we change add_7 and mul_5 to error tracking functions we also have to change composed_arithmetic to call the correct compose function. That's kind of a pain. It would be nice if we could change which type of composition we're doing without having to change every call to a composition function.

Let's go back to logging for a moment. Instead of having the functions return a tuple, let's have them return a Logging object.

class Logging:
    def __init__(self, result, message):
        self.result = result
        self.message = message

def add_7(x):
    return Logging(x + 7, 'Adding 7 to input {}\n'.format(x))

def mul_5(x):
    return Logging(x * 5, 'Multiplying input {} by 5\n'.format(x))

def monad_compose(f, g):
    # How do we define this?
    pass

composed_arithmetic = monad_compose(add_7, mull_5)
some_var = composed_arithmetic(6)

How do we define composition for functions of this type? The resulting function has to take an integer as input and should return a Logging object as output. The input needs to be passed to the first function:

def monad_compose(f, g):
    return lambda x: f(x) #...then what?

Next we need access to the result and message returned by f so we can apply it to g. Since we need access to the internals of a Logging object let's make it a method:

class Logging:
    def __init__(self, result, message):
        self.result = result
        self.message = message

    def bind(self, g):
        # The infamous bind
        gx, g_log = g(self.result)
        return Logging(gx, self.message + g_log)

def monad_compose(f, g):
    return lambda x: f(x).bind(g)

Now we've encapsulated the logic of dealing with logs into the logging object itself and made monad_compose1 entirely general. If we want to change the functions to deal with errors we can create an Error class with it's own bind method to handle things correctly and monad_compose will continue to work properly.

Many explanation of monads start by trying to explain bind and that's where a lot of confusion creeps in. It has a strange type signature and it's hard to explain exactly what bind does since it does something different for every single monad. But actually, it's not so mysterious: bind exists as a consequence of generalizing monad_compose and it's simply where we encapsulate the composition logic for the type.

The reason so many explanations of monads start by trying to explain bind is because once you have bind it's often convenient to just use it directly instead of bothering with monad_compose.

Once we've got that Logging class we can do something like this:

some_var = (
    Logging(6, '')
    .bind(add_7)
    .bind(mul_5)
)

monad_compose creates a new function that you can use repeatedly but even that can be done with bind:

composed_arithmetic = lambda x: Logging(x, '').bind(add_7).bind(mul_5)

# Or
def composed_arithmetic(x):
    return (
        Logging(x, '')
        .bind(add_7)
        .bind(mul_5)
    )

So bind is generally more useful than monad_compose but monad_compose makes it clearer what the purpose of a monad actually is: function composition.

Footnotes:

1

In pymonad.tools this function is called kleisli_compose.

Author: Jason DeLaat

Created: 2021-02-01 Mon 05:55

Validate