The exceptions
package provides three typeclasses for generalizing exception
handling to monads beyond IO
:
MonadThrow
is for monads which allow reporting an
exception
MonadCatch
is for monads which also allow
catching a throw exception
MonadMask
is for monads which also allow
safely acquiring resources in the presence of asynchronous
exceptions
For reference, these are defined as:
class Monad m => MonadThrow m where
throwM :: Exception e => e -> m a
class MonadThrow m => MonadCatch m where
catch :: Exception e => m a -> (e -> m a) -> m a
class MonadCatch m => MonadMask m where
mask :: ((forall a. m a -> m a) -> m b) -> m b
uninterruptibleMask :: ((forall a. m a -> m a) -> m b) -> m b
This breakdown of the typeclasses is fully intentional, as each
added capability excludes some class of monads, e.g.:
Maybe
is a valid instance of
MonadThrow
, but since it throws away information on
the exception that was thrown, it cannot be a
MonadCatch
- Continuation-based monads like
Conduit
are capable
of catching synchronously thrown exceptions and are therefore valid
MonadCatch
instances, but cannot provide guarantees of
safe resource cleanup (which is why the resourcet
package exists), and are therefore not MonadMask
instances
However, there are two tightly related questions around
MonadMask
which trip people up a lot:
- Why is there no instance for
MonadMask
for
EitherT
(or its new synonym ExceptT
)?
It's certainly possible to safely acquire resources within an
EitherT
transformer (see below for an example).
- It seems perfectly reasonable to define an instance of
MonadMask
for a monad like Conduit
, as
its only methods are mask
and
uninterruptibleMask
, which can certainly be
implemented in a way that respects the types. The same applies to
EitherT
for that matter.
Let's look at the docs for the MonadMask
typeclass
for a little more insight:
Instances should ensure that, in the following code:
f `finally` g
The action g is called regardless of what occurs within f,
including async exceptions.
Well, this makes sense: finally
is a good example
of a function that guarantees cleanup in the event of any
exception, so we'd want this (fairly straightforward) constraint to
be met. The thing is, the finally
function is not part
of the MonadMask
typeclass, but is instead defined on
its own as (doing some aggressive inlining):
finally :: MonadMask m => m a -> m b -> m a
finally action finalizer = mask $ \unmasked -> do
result <- unmasked action `catch` \e -> do
finalizer
throwM (e :: SomeException)
finalizer
return result
Let's specialize the type signature to the ExceptT MyError
IO
type:
finally :: ExceptT MyError IO a
-> ExceptT MyError IO b
-> ExceptT MyError IO a
If we remember that ExceptT
is defined as:
newtype ExceptT e m a = ExceptT (m (Either e a))
We can rewrite that signature to put the IO
on the
outside with an explicit Either
return value. Inlining
the Monad
instance for ExceptT
into the
above implementation of finally
, we get:
finally :: IO (Either MyError a)
-> IO (Either MyError b)
-> IO (Either MyError a)
finally action finalizer = mask $ \unmasked -> do
eresult <- unmasked action `catch` \e -> do
finalizer
throwM (e :: SomeException)
case eresult of
Left err -> return (Left err)
Right result -> do
finalizer
return result
(I took some shortcuts in this implementation to focus on the
bad part, take it as an exercise to the reader to make a fully
faithful implementation of this function.)
With this inlined implementation, the problem becomes much
easier to spot. We run action
, which may result in a
runtime exception. If it does, our catch
function
kicks in, we run the finalizer, and rethrow the exception,
awesome.
If there's no runtime exception, we have two cases to
deal with: the result is either Right
or
Left
. In the case of Right
, we run our
finalizer and return the result. Awesome.
But the problem is in the Left
case. Notice how
we're not running the finalizer at all, which is clearly
problematic behavior. I'm not pointing out anything new here, as
this has been well known in the Haskell world, with packages like
MonadCatchIO-transformers
in the past.
Just as importantly, I'd like to point out that it's
exceedingly trivial to write a correct version of
finally
for the IO (Either MyError a)
case, and therefore for the ExceptT MyError IO a
case
as well:
finally :: IO (Either MyError a)
-> IO (Either MyError b)
-> IO (Either MyError a)
finally action finalizer = mask $ \unmasked -> do
eresult <- unmasked action `catch` \e -> do
finalizer
throwM (e :: SomeException)
finalizer
return eresult
While this may look identical to the original, unspecialized
version we have in terms of MonadMask
and
MonadCatch
, there's an important difference: the monad
used in the do
-notation is IO
, not
ExceptT
, and therefore the presence of a
Left
return value no longer has any special effect on
control flow.
There are arguments to be had about the proper behavior to be
displayed when the finalizer has some error condition, but I'm
conveniently eliding that point right now. The point is: we can
implement it when specializing Either
or
ExceptT
.
Enter MonadBracket
A few weeks ago I was working
on a pull request for the foundation package, adding a
ResourceT
transformer. At the time, foundation didn't
have anything like MonadMask
, so I needed to create
such a typeclass. I could have gone with something matching the
exceptions package; instead, I went for the following:
class MonadCatch m => MonadBracket m where
generalBracket
:: m a
-> (a -> b -> m ignored1)
-> (a -> E.SomeException -> m ignored2)
-> (a -> m b)
-> m b
This is a generalization of the bracket
function.
Importantly, it allows you to provide different cleanup functions
for the success and failure cases. It also provides you with more
information for cleanup, namely the exception that occured or the
success value.
I think this is a better abstraction than
MonadMask
:
- It allows for a natural and trivial definition of all of the
cleanup combinators (
bracket
, finally
,
onException
, etc) in terms of this one primitive.
- The primitive can be defined with full knowledge of the
implementation details of the monad in question.
- It makes invalid instances of
MonadBracket
look
"obviously wrong" instead of just being accidentally wrong.
We can fiddle around with the exact definition of
generalBracket
. For example, with the type signature
above, there is no way to create an instance for
ExceptT
, since in the case of a Left
return value from the action:
- We won't have a runtime exception to pass to the exceptional
cleanup function
- We won't have a success value to pass to the success cleanup
function
This can easily be fixed by replacing:
-> (a -> b -> m ignored1)
with
-> (a -> m ignored1)
The point is: this formulation can allow for more valid
instances, make it clearer why some instances don't exist, and
prevent people from accidentally creating broken, buggy
instances.
Note that I'm not actually proposing any changes to the
exceptions package right now, I'm merely commenting on this new
point in the design space. Backwards compatibility is something we
need to seriously consider before rolling out changes.
Subscribe to our blog via email
Email subscriptions come from our Atom feed and are handled by Blogtrottr. You will only receive notifications of blog posts, and can unsubscribe any time.
Do you like this blog post and need help with Next Generation Software Engineering, Platform Engineering or Blockchain & Smart Contracts? Contact us.