OK, so it's not really good form to answer your own question, but I'm going to note down my thinking in case it enlightens anybody else. (I doubt it...)
If a monad can be thought of as a "container", then both return
and join
have pretty obvious semantics. return
generates a 1-element container, and join
turns a container of containers into a single container. Nothing hard about that.
So let us focus on monads which are more naturally thought of as "actions". In that case, m x
is some sort of action which yields a value of type x
when you "execute" it. return x
does nothing special, and then yields x
. fmap f
takes an action that yields an x
, and constructs an action that computes x
and then applies f
to it, and returns the result. So far, so good.
It's fairly obvious that if f
itself generates an action, then what you end up with is m (m x)
. That is, an action that computes another action. In a way, that's maybe even simpler to wrap your mind around than the >>=
function which takes an action and a "function that produces an action" and so on.
So, logically speaking, it seems join
would run the first action, take the action it produces, and then run that. (Or rather, join
would return an action that does what I just described, if you want to split hairs.)
That seems to be the central idea. To implement join
, you want to run an action, which then gives you another action, and then you run that. (Whatever "run" happens to mean for this particular monad.)
Given this insight, I can take a stab at writing some join
implementations:
join Nothing = Nothing
join (Just mx) = mx
If the outer action is Nothing
, return Nothing
, else return the inner action. Then again, Maybe
is more of a container than an action, so let's try something else...
newtype Reader s x = Reader (s -> x)
join (Reader f) = Reader (\ s -> let Reader g = f s in g s)
That was... painless. A Reader
is really just a function that takes a global state and only then returns its result. So to unstack, you apply the global state to the outer action, which returns a new Reader
. You then apply the state to this inner function as well.
In a way, it's perhaps easier than the usual way:
Reader f >>= g = Reader (\ s -> let x = f s in g x)
Now, which one is the reader function, and which one is the function that computes the next reader...?
Now let's try the good old State
monad. Here every function takes an initial state as input but also returns a new state along with its output.
data State s x = State (s -> (s, x))
join (State f) = State (\ s0 -> let (s1, State g) = f s0 in g s1)
That wasn't too hard. It's basically run followed by run.
I'm going to stop typing now. Feel free to point out all the glitches and typos in my examples... :-/
join m = m >>= id
– Daniel Wagnerjoin
. At least, I know the incantation that yields the correct type signature. But I'm struggling a little to wrap my mind around what that literally does... – MathematicalOrchidconcat
is one easily spottedjoin
candidate (given some rudimentary Haskell knowledge) rather than the (i.e., one & only) possibility. I raised my question out of concern that readers might take your remark literally and believe it to be true, thus missing some illuminating lines of inquiry. For instance, doesconcat
satisfy the required monad laws? Do other candidates? If yes & no (respectively), how could one deriveconcat
from the laws? I use such questions to lead me away from the "obvious", i.e., my blind spots. – Conal