When I wrote this I was going to lead in by saying: I’ve been spending a chunk of time recently thinking about how best to represent Monads in Python. Then I forgot I had this draft for 3 years. So.. I *did* spend a chunk of time. Perhaps it will be of interest anyway… though I had not finished it (otherwise it wouldn’t still be draft would it :))
Why would I do this? Because there are some nifty things you get with them: you get some very mature patterns for dealing with error (Either, Maybe), with nondeterminism (List), with DSLs (Free).
Why wouldn’t you do this? Because you get some baggage. There are two bits in particular. firstly, Monads solve a problem Python doesn’t have. Consider:
x = read_file('fred') y = delete_file('fred')
In Haskell, the compiler is free to run those functions in either order as there is no data dependency between them. In Python, it is not – the order is specified directly by the code. Haskell requires a data dependency to force ordering (and in fact RealWorld in order to distinguish different invocations of IO). So to define a sequence here it defines a new operator (really just an infix function) called bind (>>= in haskell). You then create a function to run after the monad does whatever it needs to do. Whenever you see code like this in Haskell:
do x <- action1 y >= \x action2 >>= \y return x+y
A direct transliteration into Python is possible a few ways. One of the key things though is to preserve the polymorphism – bind is dependent on the monad instance in use, and the original code is valid under many instances.
def action1(m): return m.unit(1) def action2(m): return m.unit(2) m = MonadInstance() action1(m).bind( lambda m, x: action2(m).bind( lambda m, y: m.unit(x+y)))
In this style functions in a Monad would take a monad instance as a parameter and use that to access the type. Note in particular that the behavior of bind is involved at every step here.
I’ve recently been diving down into Effect as part of preparing my talk for Kiwi PyCon. Effect was described to me as modelling the Free monad, and I wrote my talk on that basis – only to realise, in doing so, that it doesn’t. The Free monad models a domain specific language – it lets you write interpreters for such a language, and thanks to the lazy nature of Haskell, you essentially end up iterating over a (potentially) infinitely recursive structure until the program ends – the Free bind method steps forward once. This feels very similar to Effect in some ways. Its also used (in some cases) for similar reasons: to let more code be pure and thus reliably testable.
But writing an interpreter for Effect is very different to writing one for Free. Compare these blog posts with the howto for Effect. In the Free Monad the interpreter can hand off to different interpreters at any point. In Effect, a single performer is given just a single Intent, and Intents just return plain values. Its up to the code that processes values and returns new Effect’s to perform flow control.
That said, they are very similar in feel: it feels like one is working with data, not code. Except, in Haskell, its possible to use do notation to write code in the Free monad in imperative style… but Effect provides no equivalent facility.
This confused me, so I reached out to Chris and we had a really fascinating chat about it. He pointed me at another way that Haskellers separate out IO for testing. That approach is to create a class specifically for the IO in your code and have two implementations. One production one and one test implementation. In Python:
class Impure: def readline(self): raise NotImplementedError(self.readline) ... class Production: def readline(self): return sys.stdin.readline() ... class Test: def __init__(self, inputs): self.inputs = inputs def readline(self): return self.inputs.pop(0) ...
Then you write code using that directly.
def echo(impl): impl.writeline(impl.readline())
This seems to be a much more direct way to achieve the goal of being able to write pure testable code. And it got me thinking about the actual basic premise of porting monads to Python.
The goal is to be able to write Pythonic, pithy code that takes advantage of the behaviour in the bind for that monad. Lets consider Maybe.
class Something: def __init__(self, thing): self.thing = thing @classmethod def unit(klass, thing): return Something(thing) def bind(self, l): return l(self, self.thing) def __str__(self): return str(self.thing) def action1(m): return m.unit(1) def action2(m): return m.unit(2) m = Something r = action1(m).bind( lambda m, x: action2(m).bind( lambda m, y: m.unit(x+y))) print("%s" % r) # 3
Trivial so far, though having to wrap the output types in our functions is a bit ick. Lets add in None to our example.
class Nothing: def bind(self, l): return self def __str__(self): return "Nothing" def action1(m): return Nothing() def action2(m): return m.unit(2) m = Something r = action1(m).bind( lambda m, x: action2(m).bind( lambda m, y: m.unit(x+y))) print("%s" % r) # Nothing
The programmable semicolon aspect of monads comes in from the bind method – between each bit of code we write, Something chooses to call forward, and Nothing bypasses our code entirely.
But we can’t use that unless we start writing our normally straight forward code such that every statement becomes a closure – which we don’t want.. so we want to interfere with the normal process by which Python chooses to run new code.
There is a mechanism that Python gives us where we get control over that: generators. While they are often used for concurrency, they can also be used for flow control.
The problem is, that its still not regular Python code, and its still somewhat mental gymnastics. Natural for someone thats used to thinking in those patterns, and it works beautiful in Haskell, or Rust, or other languages.
There are two fundamental underpinnings behind this for Haskell; type control from context rather than as part of the call signature and do notation which makes code using it look like Python. In python we are losing the notation, but gaining the bind operator on the Maybe monad which short circuits Nothing to Nothing across an arbitrary depth of of computation.
What else short circuits across an arbitrary depth of computation?
This won’t give the full generality of Monads (for instance, a Monad that short circuits up to 50 steps but no more is possible) – but its possibly
Python basically is do notation, and if we just had some way of separating out the side effects from the pure code, we’d have pure code. And we have that from above.
So there you have it, a three year old mull: perhaps we shouldn’t port Monads to Python at all, and instead just:
- Write pure code
- Use a strategy object to represent impure activity
- Use exceptions to handle short circuiting of code
I think there is room if we wanted to to do a really nice, syntax integrated Monad style facility in Python (and Maybe would be a great reference case for it), but generator overloading – possibly async might let a nicer thing be done but I haven’t investigated that yet.