[Haskell-cafe] Monad-control rant

Edward Z. Yang ezyang at MIT.EDU
Mon Jan 16 08:15:50 CET 2012

Hello Mikhail,

Sorry, long email. tl;dr I think it makes more sense for throw/catch/mask to
be bundled together, and it is the case that splitting these up doesn't address
the original issue monad-control was designed to solve.

                                    ~ * ~

Anders and I thought a little more about your example, and we first wanted to
clarify which instance you thought was impossible to write.

For example, we think it should be possible to write:

    instance MonadBaseControl AIO AIO

Notice that the base monad is AIO: this lets you lift arbitrary AIO
operations to a transformed AIO monad (e.g. ReaderT r AIO), but no more.
If this is the instance you claimed was impossible, we'd like to try implementing
it.  Can you publish the full example code somewhere?

However, we don't think it will be possible to write:

    instance MonadBaseControl IO AIO

Because this lets you leak arbitrary IO control flow into AIO (e.g. forkIO, with
both threads having the ability to run the current AIO context), and as you stated,
you only want to allow a limited subset of control flow in.  (I think this was
the intent of the original message.)

Maybe client code doesn't want to be attached to AIO base monads, though;
that's too restrictive for them. So they'd like to generalize a bit.  So let's
move on to the issue of your typeclass decomposition.

                                    ~ * ~

I don't think it makes too much sense have thing pick off a menu of
Abort/Recover/Finally from a semantics perspective:

> It's easy to imagine monads that have an instance of one of the classes but
> not of the others....

I'd like to see some examples.  I hypothesize that most of such monads are
incoherent, semantically speaking.  For example, what does it mean to have a
monad that can recover exceptions, but for which you can't throw exceptions?
There only a few options:

    - You have special primitives which throw exceptions, distinct from
      Haskell's IO exceptions.  In that case, you've implemented your own
      homebrew exception system, and all you get is a 'Catch MyException'
      which is too specific for a client who is expecting to be able
      to catch SomeExceptions.

    - You execute arbitrary IO and allow those exceptions to be caught.
      But then I can implement Throw: I just embed an IO action that
      is throwing an exception.

    - You only execute a limited subset of IO, but when they throw exceptions
      they throw ordinary IO exceptions.  In this case, the client doesn't
      have access to any scarce resources except the ones you provided,
      so there's no reason for him to even need this functionality, unless
      he's specifically coding against your monad.

What does it mean to not have a Finally instance, but a Recover and Throw
instance?  Well, I can manually reimplement finally in this case (with or
without support for asynchronous exceptions, depending on whether or not Mask
is available): this is how the paper does it (finally is not a primitive.)

What does it mean to have a monad that can throw exceptions, but not catch them?
This is any of the usual monads that can fail, of which we have many.  And of course,
you can't allow this in the presence of scarce resources since there is no way to
properly deallocate them when exceptions are thrown.  So it seems this is just ordinary
failure which cannot be used in the presence of arbitrary IO.

What does it mean to have all of the above, but not to have a mask instance?
One approach is to pretend asynchronous exceptions do not exist.  As you do in your
example, we can simply mask.  I think this is a bit to give up, but I'll concede it.
However, I don't think it's acceptable not to provide mask functionality, not mask
your interpreter, and allow arbitrary IO.  It's now impossible to properly implement
many patterns without having subtle race conditions.

So it seems we should collapse these into one class, which conveniently maps straight
to the semantics defined in "Asynchronous Exceptions in Haskell".

class MonadAsyncExc m where
    mask :: ((forall a. m a -> m a) -> m b) -> m b
    throw :: SomeException -> m ()
    catch :: m a -> (SomeException -> m a) -> m a

But you get to have your cake and eat it too: if you define a monad which is guaranteed
to be run with asynchronous exceptions masked, you can define the 'mask' function
to be a no-op and not violate any laws! Hooray!

But this is in fact what MonadCatchIO was, except that MonadCatchIO was
formulated when we still had block/unblock and it required MonadIO.  So a
useful endeavour would be to punt the MonadIO superclass constraint and fix the
definitions, and we have something that is usable to your case.

                                  ~ * ~

To contextualize this whole discussion, recall the insiduous problem that
*motivated* the creation of monad-control.  Suppose that we've done all of the
hard work and lifted all of the Control.Exception functions to our new formula
(maybe we also need uninterruptibleMask in our class, but no big matter.)  Now
a user comes along and asks for 'alloca :: Storable a => (Ptr a -> IO b) -> IO
b'.  Crap!  We need to redefine alloca to work for our new monad. So in comes
the class Alloca.  But there are a billion, billion of these functions.  If
only there was a way of automatically getting lifted versions of them... and
this leads us back to MonadMorphIO, monad-peel, and finally monad-control.

This gist of your objection to this typeclass is that there is no principled
way to tell the difference between:

    forkIO :: IO a -> IO a


    bracketed :: IO a -> IO a

Thus, it is impossible, with the type system given to us by IO, to write a
function that will lift bracketed automatically, but not also lift forkIO.
Long live the IO sin bin.  So the best we can hope for is manually lifting
every function we think is safe.  You've attempted to work around this by
defining a typeclass per function, but as I've tried to argue in this email
that there is no way to reasonably reason about these functions in isolation.
I also hope that I've convinced you that even with all of these typeclasses,
we still only have a partial solution.

I think one way forward may be to create a mechanism by which we can identify
functions which don't alter control flow, and cheaply lift them via a
monad-control like mechanism, while not publishing that mechanism itself
(except, maybe, as an unsafe combinator).  But this is a discussion that independent
of your rant.


More information about the Haskell-Cafe mailing list