DEV Community

Ben
Ben

Posted on

The Joy of Monads

I love taking some time out in December, but my code obsessed brain often results in me using it to learn about some new coding concepts. This year, I took to reading though Miran Lipovača's awesome Learn You a Haskell for Great Good! which had previously sat on my bookshelf unread for a long time.

Probably one of the main reasons I didn't approach it was that Haskell has a reputation for being a difficult language. I still think that reputation is pretty well earned, but more than anything, mainly because its such a big language. There's a lot in Haskell, and it has a pretty complex type system. As a digression, I think this is what makes Rust famously difficult to pick up too. Rust doesn't have very many "gotchas" compared to other languages, but that doesn't stop the fact that there's a lot of completely new concepts you'll need to learn in order to pick it up.

One of the things that suprised me most, was from learning the concept of Monads, which I've always assumed where insanely complicated. Turns out, the first sentence of the wikipedia article on Monads is enough:

a monad is a structure that combines [functions] and wraps their return values in a type with additional computation

That's literally it. In other words, a monad is a design pattern where we abstract away additional work that happens on each function call.

I ended up putting together a super small library for implementing monads in python as a learning exercise. So lets work the "Result" monad from that as an example!

Take this extremely helpful code:

import requests

def get_age():
    age = input("what is your age?")
    return age

def human_to_dog_years(age):
    response = requests.get("https://animalyrconverter.com/api/v1/humantodog")
    dog_yr_per_human_yr = response.json()[converter]
    return age * dog_yr_per_human_yr

def dog_to_cat_years(age):
    response = requests.get("https://animalyrconverter.com/api/v1/dogtocat")
    cat_yr_per_dog_yr = response.json()[converter]
    return age * cat_yr_per_dog_yr

if __name__ == "__main__":
    cat_years = dog_to_cat_years(
        human_to_dog_years(
            int(get_age())
        )
    )
    print(f"you are {cat_years} cat years old!")
Enter fullscreen mode Exit fullscreen mode

I'm sure you can see lots of problems with this example that I haven't even notices, but lets just focus on one + a bonus:

  • Main problem: we don't have any structured error handling
  • Bonus: pythons nested functions are kinda janky to read

Let's ignore the bonus for now, and just implement some basic error handling to guarantee no runtime errors:

if __name__ == "__main__":
    try:
        cat_years = dog_to_cat_years(
            human_to_dog_years(
                int(get_age())
            )
        )
        print(f"you are {cat_years} cat years old!")
    except Exception as error:
        print(f"I don't know how old you are in cat years")
        print(f"This error happend: {error}")
Enter fullscreen mode Exit fullscreen mode

Python linters are all screaming right now because of the generic except not specifying an error, but lets not worry about that since this is just a toy example and instead look at what this looks like using a monad to carry out error handling instead:

from monads.monads import Result

if __name__ == "__main__":
    cat_years = (
        Result(get_age())
        .bind(int)
        .bind(human_to_dog_years)
        .bind(dog_to_cat_years
    )
    if cat_years.value:
        print(f"you are {cat_years} cat years old!")
    else:
        print(f"I don't know how old you are in cat years")
        print(f"This error happend: {cat_years.exception}")

Enter fullscreen mode Exit fullscreen mode

or, as a bonus we can use some syntactical sugar and express binds like this (don't worry too much about this part though, it's handy, but isn't needed for understanding monads):

from monads.monads import Result

if __name__ == "__main__":
    cat_years = Result(get_age()) >> int >> human_to_dog_years >> dog_to_cat_years
    if cat_years.value:
        print(f"you are {cat_years} cat years old!")
    else:
        print(f"I don't know how old you are in cat years")
        print(f"This error happend: {cat_years.exception}")

Enter fullscreen mode Exit fullscreen mode

The Result monad is basically a class wrapping a value, and giving us "bind" to run a function over it. It has a value and an exception, one of which is always None.

the bind definition just looks like this:

def bind(self, func):
    if self.exception:
        return self
    try:
        return Result(func(self.value))
    except Exception as exception:
        return Result(None, exception)
Enter fullscreen mode Exit fullscreen mode

Basically we're saying:

  • If there's already an error, don't even attempt to do anything
  • Otherwise, try to return a new Result with the updated value
  • If that fails, return a new Result with the relvant exception

I'm just aiming to explain monads here rather than evangelising them, but some motivations for this approach might be:

  • More complicated error handling behaviour is easier since you have an exception variable that isn't within the except Exception nest
  • You can pass the Result monad around different parts of your program easily, which is trickier with try/catch statements

The result handling itself though, is just an example, what makes this a monad is that it is doing the result handling on each function call, so our function calls don't need to worry about the result handling at all.

If that's still a little fuzzy, let's take a look at the Maybe monad:

import random

from monads.monads import Maybe

def maybe_not():
    return random.choice([7, None])

def double(x):
    return x * 2

def square(x):
    return x ** 2

if __name__ == "__main__":
    result = Maybe(maybe_not()) >> double >> square
    if result.value:
        print(result)
    except:
        print("nope")
Enter fullscreen mode Exit fullscreen mode

Here Maybe handles the possibility that something is None for us. Here's its definition:

class Maybe(Generic[T], Monad):
    def bind(self, func: Callable) -> "Maybe":
        if self.value is None:
            return Maybe(None)
        return Maybe(func(self.value))
Enter fullscreen mode Exit fullscreen mode

Basically, if the value is None, we won't do anything, otherwise, we'll return a new Maybe with the executed function.

This means that we don't have to think about the possibility of whether something is None within our double or square calls, which would otherwise error.

And one more, the List monad:

from monads.monads import List

def double(x):
    return x * 2

def square(x):
    return x ** 2

if __name__ == "__main__":
    result = List([1, 2, 3]) >> double >> square
    print(result)
Enter fullscreen mode Exit fullscreen mode

Here the List monad abstracts away the complexity of dealing with a List and applies a function to all the members. Otherwise we'd have to change functions like double to be this:

def list_double(x_list):
    return [x * 2 for x in x_list]
Enter fullscreen mode Exit fullscreen mode

Hopefully I've convinced you now that monads are just a pretty regular (and helpful) design pattern, and not some kind of dark magic!

To summarise, monads:

  • wrap some kind of value
  • do a bunch of extra work on function calls to that value

Which means:

  • we don't need to repeat logic on those individual function calls
  • function definitions don't need to handle additional abstractions or logic

I had a bunch of fun learning about this - hoping to dive into some more functional programming stuff next. Happy new year 🎉

Top comments (0)