Demystifying Monads using Python – Part 2
A sequencing operator
In this part, I want to show that the same formalism we developed for
progamming with SMFs can be used to express programs that perform I/O.
But I want to do another couple of things before leaving the world of SMFs.
The first thing is to define an
infix operator for sequ.
To do this, we will need to create a wrapper for our SMFs. In Haskell,
we can define an operator stand-alone like a function, but in Python we
need a class to hang it off.
class SMF:
def __init__(self, f):
self.f = f
def __call__(self, *args):
return self.f(*args)
def __rshift__(self, g):
return SMF(lambda h: self.f(lambda s: g(h)(s)))
Our primitive operations now become:
add = lambda x: SMF(lambda f: lambda s: f(s | {x}))
listify = SMF(lambda f: lambda s: f(list(s))(s))
(There's an incidental benefit to introducing this wrapper. It makes
explicit something that wasn't quite so obvious before: each of the
functions we're sequencing is either an SMF itself or a function that
returns an SMF. In Haskell, you would be able to see this from the
types of the functions.)
We can now write dedup as
dedup = lambda l: (
add(l[0]) >> dedup(l[1:])
if l else
listify)
Dealing with "return" values
The other thing concerns SMFs that produce a result for use by later
steps in the computation. So far, the only one we've seen that does
this is listify, and we only used it right at the very end. If we had tried to use it in the middle of a computation, we would have noticed a slight problem: as currently defined, our sequencing operation can't cope with that!
To see the issue more clearly, let's tackle another task. We'll write a program decadup that does the same thing as dedup, except that if it encounters a number it's already seen, it adds 10 times that number to the output list.
To do this we're going to need another primitive that tests whether a
value is a member of the set. This primitive will produce a boolean
result, and in accordance with the conventions we established, it will
pass the result as an extra argument to its continuation.
member = lambda x: SMF(lambda f: lambda s: f(x in s)(s))
Now let's have a go at implementing decadup. We want to write something like this:
# Warning: This doesn't work!
decadup = lambda l: (
member(l[0]) / (lambda b:
add(10 * l[0]) if b else add(l[0]))
>> decadup(l[1:])
if l else
listify)
The red part here is meant to be the continuation seen by the call to member. It receives the result from member in b and uses it to decide whether to add l[0] or 10 * l[0] to the set.
The reason it doesn't work can be seen by examining the definition of / above.
def __truediv__(self, g):
return SMF(lambda h: self.f(lambda s: g(h)(s)))
The red part here is the actual continuation that gets passed to member, and it doesn't expect to receive a result argument, only the state s.
The easiest way to fix this is to define a second sequencing operator
that takes a result and passes it on. This operation is usually called
"bind", because it binds the result to a variable for use by subsequent
steps of the computation.
In Haskell the binding operator is usually spelled >>=, but that's not available to us in Python, so I'm going to use **. We add a method to the SMF class:
def __pow__(self, g):
return SMF(lambda h: self.f(lambda x: lambda s: g(x)(h)(s)))
The red part shows how ** differs from >>. The result is picked up in x and passed on to g.
Now we can write a working version of decadup:
decadup = lambda l: (
member(l[0]) ** (lambda b:
add(10 * l[0]) if b else add(l[0]))
>> decadup(l[1:])
if l else
listify)
The result of run_smf(decadup, [5,4,3,6,8,2,8,4,5]) is [2, 3, 4, 5, 6, 8, 40, 80, 50].
Handling I/O
So far, the state object we've been dealing with has been an in-memory
data structure. However, it's not too hard to imagine passing around an
object w with methods such as print() and input()
that affect the state of the world. Just as we could get away with
mutating our set object in-place, we can get away with mutating the
outside world in-place. We will never be in the embarrassing position
of needing access to the state of the world at an earlier time, which
(unless you're Guido and therefore have a time machine) is not easy to
come by.
The world object might look something like this:
class World:
def print(self, x, f):
print(x)
f(self)
def input(self, f):
x = input()
f(x)(self)
Then we could define primitives
io_print = lambda x: IO(lambda f: lambda w: w.print(x, f))
io_input = IO(lambda f: lambda w: w.input(f))
Here, IO is assumed to be a wrapper class like SMF that provides suitable definitions of the sequencing operators.
But if you think about it a bit, we don't really need to pass the world
object around, because it doesn't contain any state of its own. There
could be a single global instance, or – more Pythonically – we could
just turn its methods into top-level functions. Let's do that:
def world_print(x, f):
print(x)
f()
def world_input(f):
x = input()
f(x)
Now we can drop the w parameter from our primitives:
io_print = lambda x: IO(lambda f: world_print(x, f))
io_input = IO(lambda f: world_input(f))
To complete the picture, we need to supply a wrapper class. This is
essentially identical to the SMF wrapper, except that there is no state
parameter.
class IO:
def __init__(self, f):
self.f = f
def __call__(self, *args):
return self.f(*args)
def __rshift__(self, g):
return IO(lambda h: self.f(lambda: g(h)))
def __pow__(self, g):
return IO(lambda h: self.f(lambda x: g(x)(h)))
We'll also need a top-level function to run our IO programs. We can get by with something a lot simpler than run_smf, because we don't need to provide for passing in input or getting back output – our IO
program will be perfectly capable of doing those things for itself. We
can just fire it straight off with a final continuation that does
nothing.
def run_io(f):
return f(lambda: None)
Now we can write a test program.
greet = (
io_print("Hi, what's your name?") >>
io_input ** (lambda name:
io_print("Aren't monads great, " + name + "?")))
To show that it works, here's a sample session:
>>> run_io(greet)
Hi, what's your name?
Greg
Aren't monads great, Greg?
>>>
I/O in Haskell
In Haskell, the things corresponding to our Python io_print and io_input functions are called actions. The type of an I/O action is called IO. (Technically it's IO t where t is the type of the result produced by the action.)
The Haskell equivalent of tbe greet program above would be
main = putStrLn "Hi, what's your name?" >>
getLine >>= \name ->
putStrLn ("Aren't monads great, " ++ name ++ "?")
This is a bit neater, because we're able to use more suggestive
operator symbols, and also the grammar doesn't require putting extra
parentheses around the lambda expression.
However, Haskell goes further and provides a special syntax for dealing with monads:
main = do
putStrLn "Hi, what's your name?"
name <- getLine
putStrLn ("Aren't monads great, " ++ name ++ "?")
The do construct is syntactic sugar that gets macro-expanded into an equivalent expression involving >>, >>= and lambdas.
Note that Haskell doesn't provide any equivalent of our Python run_io function, nor is there any way you could write one yourself. Instead, you just define the name main to be something of type IO, and the Haskell system knows what to do with it.
A consequence of this is that all the I/O done by a Haskell program is,
in a sense, at the top level. I/O actions can appear as parts of other
I/O actions, but you can't embed I/O in the middle of an "ordinary"
function.
What has happened here?
At this point, you might be thinking that there is some sleight of hand
going on here somewhere. We started with something that was evidently
purely functional, and seemingly without any abrupt transition, ended
up doing I/O, which is evidently not functional!
What we have done is construct an algebra of actions. The I/O actions themselves are not
functions in the mathematical sense – they don't take input as
arguments and produce a result as a return value. But there's nothing
wrong with that – lots of other things that functional programs
manipulate are not functions, integers and strings for example.
On the other hand, the operations such as >> that we have defined on actions are functions -- they take actions as arguments and produce other actions as results. When we write something like greet we're writing a function whose return value is an action.
So we have a closed system – a set of actions, and a collection of
operations that take actions and produce other actions. There is an
algebraic structure to this system, so we can apply algebraic reasoning
to it.
And that's one reason why Haskell programmers like monads so much – they let you perform algebra on programs that do I/O.