Monads are famously hard to explain. I'm not going to any better. You can find many attempts online with little effort, so I'm going to try and answer a different question.
Monads are a useful protocol. You can use them to separate your program into two parts; I think of it as the Business Logic and the Plumbing. The useful bit about Monads is "Plumbing" can be just about any boilerplate that needs to run between each piece of Business Logic.
There are a lot of blogs that start like this too. This is where we look at the Maybe and Either Monads. The Maybe and Either Monads are cousins with similar Plumbing; the Business logic returns a thing, and the Plumbing decides whether to run the next bit of Business Logic or short circuit and drop everything because it's just not going to work out.
Having introduced the 'simple' Maybe and Either monads, most blogs in this style end. Of course it's not very impressive. There are better error handling features built into many languages (not Go, but most languages), so who cares?
I think this is a point that gets missed though; error handling is a type of Plumbing, but Plumbing can do other things. It can represent State which some of you Business Logic wants to access but other bits want to ignore. It can represent the whole outside world your program wants to communicate with. It can count the times each bit of your business logic has been invoked. It can implement call-with-current-continuation. And so on and on.
There are two main weaknesses of Monads, of course. The first is that most languages have explicit mechanisms for the simple useful examples people give for Monads (associating state with functions, dealing with IO, handling errors, profiling, no one actually uses call-with-current-continuation so who cares, and so on). The second problem is obvious: they are confusing as fuck and no one has figure out how to Explain It Like I'm Five. This is true of many things (you, dear reader, have probably forgotten how difficult pointers and divide and conquer recursion were to learn), but people tend to either avoid or fixate on what they don't understand[0].
Of course, maybe Monads are useful for things other than mabye. Things that are program specific, and for which there are no explicit mechanism is baked into your language.
Which brings us to part three of this essay: story time.
I started a new project in a new language (Clojure). It's a game and I want to roll some dice. Ok, so fine, there's a rand-int
function. Good, but how am I going to deal with it in tests?
I can write my functions and pass rand-int
into them:
(defn d [rand sides] (+ 1 (rand sides)))
Well and good, I can pass rand-int in production, and (d (rigged-rolls 1 2 3) 20)
in tests. with something like:
(defn rigged-rolls [& vals]
(let [vs (volatile! vals)]
(fn [] (let [[v & vs'] vs] (vreset! vs vs') v))))
Ok, time to compose. This is a D&D thing, so lets roll with advantage:
(def advantage [rand] (max (d rand 20) (d rand 20)))
Two small problems: passing rand
everywhere is icky, and the definition of rigged-rolls
is also icky (also, is argument evaluation order well specified? If not I can see adding a bunch of hard to catch bugs with this style).
So, I know about this thing called monads. Lets do that thing. I can find a few clojure monad implementations, but only one seems to work with ClojureScript (funcool/cats) so off we go. I kind of remember this stuff, so now d
looks like:
(defn d [sides]
"Roll a dice of with some number of `sides`, using a rand-int found in its state monad."
(m/mlet [[f seed] (state/get)
:let [[r next-seed] (f seed sides)]
_ (state/put (random-state f next-seed))]
(m/return (+ 1 r))))
Ok, not too bad. Don't know that it looks better than rigged-rolls
, but here we are. Now advantage looks like:
(def advantage
"Roll with advantage"
(m/mlet [a (d 20) b (d 20)]
(m/return (max a b))))
which seems fine and good to me.
However now I have a collection of character sheets and I want to roll initiative for these bad boys. Currently they're in a map of (-> id character), but I haven't need to look them up by ID yet, so I may wind up with a vector of characters, or who knows. I'd like to have some generic way map over collections so I don't have to dig around deep in various functions if it changes. So I start looking into specter (a Clojure library for nested collection traversal). It seems.. possible to wrap up the src_clojure{specter/transform} function.
I'd like a version of transform like (=> (Monad m) (-> a (m b))) so I can use it with the nice syntax like srcclojure{cast.core/mlet}. I gave implementation a shot, but I need a monad to stuff into the functions I'm mapping (e.g. my srcclojure{roll/d}) and, in general, the monad wouldn't be available when m-transform applies. Actually, in general the monad is not available at all: bind passes a monad's contents, not the monad. In this srcclojure{cats} implementation there may be a way to recover the containing monad from the srcclojure{context}, but that seems.. like it's probably going to have edge cases and just kind of gross.
So, I could probably make an m-transform that's (=> (Monad M) (-> (m a) (m b)), but then I can't compose it with src_clojure{cast.core/mlet} et alia or otherwise use m-transform in functions which are "inside" the random-rolls monad. It means I'm, in general, back to providing state explicitly to my functions, and avoiding that is the reason I bothered with this nonsense at all.
After messing with it for a while I got something working (turns out the cats/state doesn't have user docs, presumably they were lost when it was deprecated, removed, then brought back. Also, there's not really any explanation (user or otherwise) for cats/context, which is where state actually store the, well, state).
It's called m-transform even though it really only works on state:
(defn m-transform [path f coll]
(m/mlet [starter (state/get)]
(let [seed (volatile! starter)]
(m/return
(transform
path
(fn [i]
(let [p (state/run (m/mlet [v (f i)] (m/return v))
@seed)]
(vreset! seed (.snd p))
(.fst p)))
coll)))))
So, pretty gross, and I don't know how sound it is, but it works in a couple of tests. There's probably a way to generalize m-transform over all monads, but guess what? Turns out Clojure has language level features for mocking functions out in tests. So forget this. roll/d
can just call rand-int and be done with it. This was fun to mess with, but I don't see the path forward with my m-transform and I'm going to use the language feature.
I don't know. It seems like I'm doing something wrong with the Monad abstraction and there's a better way to use them near by. I've spent more than a few hours on this though, there's a more straightforward solution, and it is time to move on. In fact, I found out about with-redefs
a couple days ago, and should just have moved on then, but I figured I'd spend a little more time and get blog post out of the experience.
[0] Obviously this is why I'm writing this blog post. I don't understand Monads, or at best I understand them like I understand the Y combinator: it was a required part of my studies, I know what it does and it's general shape, and I used it in a handful of small projects. If required (during an especially stressful whiteboard interview perhaps) I could probably (maybe) re-discover either the Y combinator or Monads. They have too many pieces though, and the pieces are too abstract for me to really feel like I have a solid handle on it. Sometimes I think I understands these things as well as anyone really does. Sometimes that thought makes me happy, sometimes sad, most often I think there are people much smarter than me out there, and to them Y combinators and Monads are just no problem.