Comprehending monads
In this post, we explore the relationship between Monads and list comprehension and implement some ideas from Haskell in Python.
List comprehension is a very useful and commonly used feature in both Python and Haskell. Many people will be familiar with the following way to calculate the squares of a list of numbers in Python
return
This is almost exactly the same in Haskell
squares xs = [x ^ 2 | x <- xs]
But what is actually going on here? Although Python does not have static typing let's agree that the function squares
takes a list of numbers as input and returns a list of numbers as well. In Haskell, we'd write this type signature as
But why are lists interesting? A list is a type that wraps another type. We could say "list" is a context that we can put values in.1 With this notion of "contexts", we can rephrase our understanding of list comprehension. When we write x ** 2 for x in xs
what we are saying is: "xs
is a context that contains x
. Please take the value(s) out of their context and give us their square(s) in another list."
In Haskell, there is another way to express the above list comprehension using "do-notation".
squares xs = do
x <- xs
return (x ^ 2)
This reads almost exactly like the above formulation of our intent. Do-notation is a very generic concept though. It is syntactic sugar for dealing with generic "contexts". In Haskell they are called "monads" and have a formal definition. Put simply, for a type to be a monad there must be a return
function2 that puts a value in a context. For lists this is simply the list constructor. In Python, we could write
return
Additionally, there must be a function called bind
(in Haskell this is written as the infix operator >>=
) which takes a value in a context and a function from the type of that value to another type wrapped in the same context. Or formally
For lists in Python we could implement this as follows
=
+=
return
Note that this is subtly different from mapping f
over xs
. Now how does this relate to list comprehension and the do-notation? With those definitions of list_return
and list_bind
, we could rewrite our squares function like this
return
Or equivalently in Haskell
squares' xs = xs >>= \x -> return (x ^ 2)
If we compare this to the version in do-notation, we can see how it's really just syntactic sugar.
squares xs = do
x <- xs
return (x ^ 2)
More monads
If all we ever dealt with was lists, this whole exercise would be pretty useless.3 Luckily for us lists aren't the only monads in existence. Haskell has a type called Maybe
. Instances of Maybe
can be either Just
a value or Nothing
. The monad operations for Maybe
are defined as follows
return x = Just x
Nothing >>= f = Nothing
Just x >>= f = Just (f x)
Earlier, we discussed how do-notation was just syntactic sugar for using return
and bind
while for lists, list-comprehension was just syntactic sugar for do-notation. And in fact, in Haskell we can write the following (seemingly crazy) code once we activate the monad comprehensions language extension.
= [x ^ 2 | x <- mx]
squareMaybe mx
which if applied on Just 5
gives Just 25
. If it's applied on Nothing
, the result is still Nothing.
As you might have noticed, our squareMaybe
function is exactly the same as our initial squares
function. With monad comprehensions activated, its type signature becomes
Python, maybe?
In Python, we could mimic this behaviour by treating None
as nothing and a value as Just
a value.
return
return None
return
This is pretty useful if we have a bunch of computations that could potentially return None
and we want to chain them together. While this implementation is certainly universal (it gives us the monad operations for Maybe
without a corresponding type) it doesn't give us monad comprehension.
In order to get this behaviour in Python, we need to go a little bit crazy and (ab)use iterators. What we will do is create dedicated types for Nothing
and Just
and make them iterable so we can use them in generator expressions. Finally we will introduce a convenience method to get around the limitation of Python's []
being closely associated with lists and nothing else.
First, let's start with Nothing
.
return
return
By defining the __iter__
method, we make instances of Nothing
iterable but every iteration will immediately stop because of next
raising the StopIteration
exception when it's called.
Similarly, we can define Just
as follows.
=
return
yield
return
Using this, we can implement return
and bind
.4
return
return
return
And we're almost done. In order to get comprehension for our Maybe
, we need one extra step. Right now, we could write something like
and it would work. However, the result would not be Just 25
but rather [25]
which is not what we want. We would have replaced our maybe by a list. We get around this by introducing a convenience function
return
return
which allows us to write
and get the expected result Just 25
. Pretty neat, isn't it?
Feedback, questions, comments?
If you want to point out errors in the above text, have inquiries, or simply want to tell me how reading it was a complete waste of your time, feel free to go to the corresponding issue on GitHub, in order to discuss this article.
In Python, the types of a list's elements can be heterogeneous which brings with it a bunch of problems that we simply choose to ignore at this point.
Not to be confused with return
in Python. Luckily, in the future, this will be renamed to pure
. The technical details can be found on the corresponding mailing list
You might argue that it is useless anyway…
To make things a bit nicer (and not use type
), we could of course define a bind
member function in Nothing
and Just
and call that one from maybe_bind
.