Functional Programming illustrated in Python: Part 5

The IO Monad — laid bare

Brian Candler
9 min readNov 1, 2020

From the Functional Programming illustrated in Python series

But I don’t like Monads!

Monads, Monads, Monads… have you got anything without Monads?

Well, there’s Direct Function Application. That doesn’t have much Monad in it.

The problem is, pure functions don’t do anything. They calculate and return values derived only from their inputs — and that’s all.

Sooner or later you’re going to want to write a program which interacts with the real world. It reads and writes to the terminal. It writes to the filesystem. It updates a SQL database. It turns a little red LED on and off. All of these things are decidedly stateful and side-effect-ful, and they are implemented in dirty, impure languages like C and ultimately machine code. That code needs to be boxed up and presented in a functional way in order to interact safely with functional code. That box is conventionally¹ a Monad: the IO Monad.

How does it work? It turns out to be quite simple. The IO Monad is a class. The instances of this class represent requests to do something: “read from the console”, “write this string to the console”, “perform this SQL update” and so on. Since these are just values, they can be returned from pure functions.

The bind operation on this value then performs the requested action, then invokes the next function in the chain, with the result of the action (if any) as its argument. All the impure stuff resides within the bind method.

This might seem like sleight-of-hand, or even pointless. What’s the difference between invoking a system call directly, versus returning a value which means “please invoke this system call”? Well, as I said, a value is just a value, and can be returned from a pure function. Pure functions are completely deterministic, easy to test, and easy to reason about.

Warning: I am now going to unpick some Haskell code to expose the plumbing in Python. Any inaccuracies are entirely my fault. I welcome corrections from experts.

Let’s do it

Let’s wrap I/O so that it can be used by pure functional code. The end result should do the same as this imperative Python code:

print("What's your name?")
name = input()
print("Hello " + name)

The corresponding Haskell looks remarkably similar:

main = do
putStrLn "What's your name?"
name <- getLine
putStrLn ("Hello " ++ name)

But underneath it’s very different.

  • getLine is not a function! It’s a value from the IO class. This value tells the bind operation to read a line of text, and invoke the next function² with that text as its argument.
  • putStrLn is a function. But it doesn’t print a line of text! Rather, it returns a value from the IO class. That value tells the bind operation to print a particular line of text, and then invoke the next function.

Each of these IO values represents an “action”, something “to be done”, whose result is passed to “the next thing” (a.k.a. “the continuation”)

It might sound a bit weird. Nonetheless, this can still be translated into Python.

A class that represents Actions

Here is a naïve, but easy-to-understand, implementation of an IO action class.

class IO:
def __init__(self, action, arg):
self.action = action
self.arg = arg
def __rshift__(self, func): # this is "bind" (>>)
if self.action == "getLine":
line = input()
return func(line)
elif self.action == "putStrLn":
print(self.arg) # always returns None
return func(None)
elif self.action == "return":
return func(self.arg)
else:
raise RuntimeError("oops")
@staticmethod
def unit(v):
return IO("return", v)
def putStrLn(text):
return IO("putStrLn", text)
getLine = IO("getLine", None)

(Runnable code here)

The IO class contains a description of an action to be done, and the >> bind operation performs it. In each case, the value resulting from the action is given as the argument to the next function (on the right-hand side of >>), and the return value of that function is returned, unchanged. The no-op action “return” just passes a wrapped value straight through.

That’s fine, although I don’t like that putStrLn and getLine are global, so I am going to move them inside the IO class for tidiness:

class IO:
...
@staticmethod
def putStrLn(text):
return IO("putStrLn", text)
IO.getLine = IO("getLine", None)

That’s a bit better. Remember that getLine is not a function: it’s a constant value, an instance of the IO class, so it can’t be created until the class has been created. putStrLn is a function which returns an IO value.

(Updated code here)

A better class

But there’s a more compact and natural way to do this. Each action can be represented as a function: an impure function, with no parameters, just like say_hello from part 0 of this series. We can store the action function directly inside the IO wrapper. It boils down to just this:

class IO:
def __init__(self, action):
self.action = action
def __rshift__(self, func):
return func(self.action())
@staticmethod
def unit(v):
return IO(lambda: v)
@staticmethod
def putStrLn(text):
return IO(lambda: print(text))
IO.getLine = IO(lambda: input())

Think about this carefully. Consider these examples:

v = IO.putStrLn("Hello")   # what is the value of v ?
# does anything get printed yet? why?
def dummy(x):
pass # do nothing
v >> dummy # what does this do?IO.getLine # what is this value?
# does it read anything yet? why?
IO.getLine >> dummy # what does this do?

If that’s not clear, look again at the one-line body of the bind operation:

def __rshift__(self, func):
return func(self.action())

self is the IO value on the left-hand side of >>, and func is the function value on the right-hand side.

Step one is to pick out the action which this IO wrapper contains:

return func(self.action())
^^^^^^^^^^^

Step two is to execute it, which will do some action and give a result³:

return func(self.action())
^^

Step three is to pass that value to the right-hand function:

return func(self.action())
^^^^^ ^

And step four is to return the value returned by that function, to the caller of bind.

Therefore, when we create an instance of the IO class, we just need to provide a lambda which does the action we want to do, and returns a value to be passed on to the function on the right³.

See how the ordering is enforced. The action must be executed before its result is passed to func, since its return value forms the argument to func.

The main program

You can’t see any bind operations in the Haskell code, because they are hidden within the special syntax of the do block⁴.

To rewrite the do block with its funny <- as plain lambdas and binds, the lines are transformed one by one as described in the Assignments article. To recap:

do                               expr >> (lambda x:
x <- expr ⟾ do ...more code...
...more code... )

There is an extra case: you may have an expression whose unwrapped value is not used. Treat a bare expr as if it were ignore <- expr, unless it’s the last one.

do                               expr >> (lambda ignore:
expr ⟾ do ...more code...
...more code... )

The ignored parameter is conventionally named _ (an underscore).

The result of these transformations is the following Python:

main = (
IO.putStrLn("What's your name?") >> (lambda _:
IO.getLine >> (lambda name:
IO.putStrLn("Hello " + name)
)
)
)

At each stage, the value is an IO “action”. When bound (>>), the bind operator performs that action and passes its result as the argument to the function on the right-hand side (the continuation). That’s how IO.getLine >> (lambda name: ...) assigns the parameter name to the result.

Finally, we need to run the program. What is main anyway? Is it a function? No — for one thing, it doesn’t have any parameters, and a pure function with no parameters is a constant. It’s a value of some sort. It’s an IO action value: a chain of actions for the whole program.

To perform that action, we have to bind it — to a dummy function which does nothing and returns a dummy IO value⁵.

main >> (lambda _:
IO.unit(None)
)

But in Python that explanation is not quite true. Python is an “eager” language, meaning it evaluates things as it goes along. In the process of calculating a value for main, it performs the side effects of printing text and reading a line. The only action it doesn’t perform is the final one, which is the value assigned to main. So by the time we have assigned a value to main, it has done all the actions apart from the last one. The final bind does that.

In contrast, Haskell is a “lazy” language, which means it doesn’t evaluate expressions until it needs their value.

Anyway, here is the full code:

To make it more compact, you can rewrite those static method definitions as lambdas:

That is getting seriously terse. Welcome to functional programming.

Inside each IO(...) constructor is a function which takes no arguments, does something, and returns a value (or None). You could simplify IO.getLine even further to just this:

IO.getLine = IO(input)

I believe what I have presented above is an accurate translation of the Haskell shown earlier, but as I said before, I welcome corrections from those who know better.

Stripped bare like that though, it also shows there’s really nothing to it. The actions are still the same original impure actions, input() and print(...), held inside an IO wrapper. The difference is that the result of each action is passed along by invoking the next function in the chain — the “continuation passing” style.

By the way, do you see how easy it is to add new actions? Try adding IO.readFile, which takes a filename as its parameter and yields the contents of that file. In main, you should then be able to replace IO.getLine with IO.readFile("/etc/hostname").

Unit in action

The IO.unit function hasn’t served much purpose so far. To demonstrate it, I’m going to steal another example directly from the Haskell Wiki: the function promptTwoLines asks for two pieces of information, and returns the concatenation of them.

promptLine prompt = do
putStrLn prompt
getLine
promptTwoLines prompt1 prompt2 = do
line1 <- promptLine prompt1
line2 <- promptLine prompt2
return (line1 ++ " and " ++ line2)

main = do
both <- promptTwoLines "First line:" "Second line:"
putStrLn ("You said " ++ both)

Now a literal translation, where Haskell’s return is our IO.unit:

Note the final expression in promptTwoLines:

        IO.unit(line1 + " and " + line2)

This is the value which will be returned from promptTwoLines. The final value of one of these chains of IO actions must be an IO action, but rather than actually perform any IO, in this case we just need to wrap a calculated value (as if we’d just received it using getLine, say). Haskell’s return means “wrap this value to be returned”. It’s not the same as Python’s return, which is a flow-control construct (“stop executing this function right now”).

The value wrapped in IO is then unwrapped by bind at the point it is used, in this case as the parameter both of the next lambda:

   promptTwoLines(....) >> (lambda both:
putStrLn("You said " + both)
)

Is IO a Monad?

Or is it a burrito? You decide.

Actually, all you have to do is to check whether the IO class constructed above fulfils the Monad Laws given before. This is left as an exercise for the reader.

You can understand how this code works without knowing whether or not IO is a Monad. But if you wanted to pass the IO class to something else which works with Monads in general, then it would be important.

Acknowledgements and further reading

[1] Monads aren’t the only way to do I/O in functional languages.

[2] The “next function” being the function which is passed as an argument to bind.

[3] The action function returns a plain, unwrapped value. It might be None, but that’s still a value.

[4] In Haskell the bind operator, when it appears explicitly, is >>=. I am using >> because the code on the right is Python not Haskell, and Python doesn’t let me redefine the >>= operator. This may be confusing, because Haskell has its own >> operator which means something slightly different. Sorry, but I couldn’t see a way to avoid that.

[5] It returns IO.unit(None) for correctness, because the function that bind invokes should always return a value of a wrapped type. But here this value is passed back through bind and discarded, and since Python isn’t type-safe it could be anything.

--

--