Duncan Coutts kicked off a discussion on the core libraries mailing list in April about exception handling in monad transformers. We made a lot of headway in that discussion, and agreed to bring up the topic again on the libraries mailing list, but unfortunately none of us ever got around to that. So I'm posting a summary of that initial discussion (plus some of my own added thoughts) to hopefully get a broader discussion going.
The initial thread kicked off with a link to ghc's ExceptionMonad typeclass, which encapsulates the ability to catch exceptions, mask async exceptions, and guarantee cleanup actions (a.k.a. bracket/finally). The question is: is there a canonical way to do the same thing, without depending on the ghc library?
As is usually the case in the Haskell ecosystem, the answer is that there are about five different packages providing this functionality. I'd break them down into two categories:
- Packages which define a typeclass (or set of typeclasses) specifically for exception handling. Such examples include MonadCatchIO-mtl, MonadCatchIO-transformers, and exceptions.
- Packages which define a generic way to embed a monad transformer inside the value, and thereby perform any control operation in the base monad. Examples are monad-peel and monad-control (or if you want to go really far in time, neither).
Fortunately, most of those options have been deprecated in favor of alternatives. Today, there are really two choices: exceptions and monad-control. I'd like to describe these in a bit more detail, and contrast some of the pluses and minuses of both approaches. My overall goals are twofold:
- Get more knowledge out there about the advantages of the two libraries.
- Work towards a community consensus on when each library should be used.
I'm interested in the latter point, since having a consistent usage of the
MonadBaseControl
vs MonadMask
typeclasses in various packages makes it
easier to reuse code.
Note: I don't mean to take credit for the ideas that are expressed in this blog post. As I said, it's a combination of summary of a previous discussion (mostly amongst Duncan, Edward, and myself) and a few new thoughts from me.
exceptions
The exceptions package exposes three typeclasses, all specifically geared at
exception handling, and each one incrementally more powerful than the previous
one. MonadThrow
is for any monad which can throw exceptions. Some examples of
instances are:
IO
, where the exception becomes a runtime exception.Either
, where the exception becomes theLeft
value.Maybe
, where an exception results inNothing
.- Any monad transformer built on top of one of those. (Note also that there's a special
CatchT
transformer, which keeps the exception in the transformer itself.)
In addition to just throwing exceptions, you often want to be able to catch
exceptions as well. For that, the MonadCatch
typeclass is available. However,
some monads (in particular, Maybe
) cannot be MonadCatch
instances, since
there's no way to recover the thrown exception from a Nothing
.
The final typeclass is MonadMask
, which allows you to guarantee that certain actions are run, even in the presence of exceptions (both synchronous and asynchronous). In order to provide that guarantee, the monad stack must be able to control its flow of execution. In particular, this excludes instances for two categories of monads:
- Continuation based monads, since the flow of execution may ignore a callback entirely, or call it multiple times. (For more information, see my previous blog post.)
- Monads with multiple exit points, such as ErrorT over IO.
And this is the big advantage of the exceptions package vs MonadCatchIO: by
making this distinction between catching and masking, we end up with instances
that are well behaved, and finally
functions that guarantee cleanup happens
once, and only once.
One design tradeoff is that all exceptions are implicitly converted to SomeException
. An alternate approach is possible, but ends up causing many more problems.
monad-control
monad-control takes a completely different approach. Let's consider the StateT
Int IO
monad stack, and consider a function
foo :: StateT String IO Int
Now suppose that I'd like to catch any exceptions thrown by foo
, using the
standard try
function (specialized to IOException
for simplicity):
tryIO :: IO a -> IO (Either IOException a)
Obviously these two functions don't mix together directly. But can we coerce
them into working together somehow? The answer lies in exposing the fact that,
under the surface, StateT
just becomes a function in IO
returning a tuple.
Working that way, we can write a tryState
function:
tryState :: StateT String IO a -> StateT String IO (Either IOException a)
tryState (StateT f) = StateT $ \s0 -> do
eres <- tryIO $ f s0
return $ case eres of
Left e -> (Left e, s0)
Right (x, s1) -> (Right x, s1)
(Full example on School of Haskell.) The technique here is to:
- Capture the initial state.
- Use
tryIO
on the rawIO
function. - Case analyze the result, either getting an exception or a successful result and new state. Either way, we need to reconstitute the internal state of the transformer, in the former by using the initial state, in the latter, using the newly generated state.
It turns out that this embedding technique can be generalized in two different ways:
- It applies to just about any
IO
function, not just exception functions. - It applies to many different transformers, and to arbitrarily deep layerings of these transformers.
For examples of the power this provides, check out the lifted-base package, which includes such things as thread forking, timeouts, FFI marshaling.
This embedding technique does not work for all transformers. As you've probably guessed, it does not work for continuation-based monads.
Compare/contrast
Even though these libraries are both being proposed for solving the same problem (exception handling in transformer stacks), that's actually just a narrow overlap between the two. Let's look at some of the things each library handles that the other does not:
- exceptions allows throwing exceptions in many more monads than monad-control works with. In particular, monad-control can only handle throwing exceptions in
IO
-based stacks. (Note that this actually has nothing to do with monad-control.) - exceptions allows catching exceptions in many more monads as well, in particular continuation based monads.
- monad-control allows us to do far more than exception handling.
So the overlap squarely falls into: throwing, catching, and cleaning up from
exceptions in a monad transformer stack which can be an instance of
MonadMask/MonadBaseControl, which is virtually any stack without
short-circuiting or continuations, and is based on IO
.
Given that the overlap is relatively narrow, the next question is- if you have a situation that could use either approach- which one should you use? I think this is something that as a community, we should really try to standardize on. It would be beneficial from a library user standpoint if it was well accepted that "oh, I'm going to need a bracket here, so I should use MonadXXX as the constraint," since it will make library building more easily composable.
To help kick off that discussion, let me throw in my more subjective opinions on the topic:
- exceptions is an easier library for people to understand. I've been using monad-control for a while, and frankly I still find it intimidating.
- If you're writing a function that might fail, using
MonadThrow
as the constraint can lead to much better code composability and more informative error messages. (I'm referring back to 8 ways to report errors in Haskell.) - exceptions allows for more fine-grained constraints via MonadThrow/MonadCatch/MonadMask.
- monad-control makes it virtually impossible to write an incorrect instance. It's fairly easy to end up writing an invalid
MonadMask
instance, however, which could easily lead to broken code. This will hopefully be addressed this documentation and education, but it's still a concern of mine. - monad-control requires more language extensions.
- While there are things that exceptions does that monad-control does not, those are relatively less common.
Overall, I'm leaning in the direction that we should recommend exceptions as the standard, and reserve monad-control as a library to be used for the cases that exceptions doesn't handle at all (like arbitrary control functions). This is despite the fact that, until now, all of my libraries have used monad-control. If the community ends up deciding on exceptions, I agree to (over time) move my libraries in that direction.