There's been quite a bit of buzz about algebraic effects and handlers lately, and of course, discontent with the status quo, monad transformers. One point made (among many) is that monad transformers essentially specify statically how effects interact, and that creating more dynamic seeming behavior can be confusing. This is true, although perhaps the latter part is debatable. In this article, I'd like to argue the thesis that the staticness can actually be a useful property, that certain ways of harnessing it can make the confusing constructions less so, and provide some hints that algebraic effects perhaps aren't so different from what we are already using.
Delimited Continuations
Obviously the best place to start on this journey is delimited continuations. The core idea of continuations is that they allow one to capture up 'the rest of the program' as a first class value, to be jumped back to in some way at a later point. Delimited continuations allow one to place markers in the program, such that instead of capturing the entire 'rest of the program,' one only captures 'the rest of the program until we get to a marker.' This generally makes them a bit better behaved than traditional continuations.
One way of providing delimited continuations in Haskell is given by the CC-delcont package. It provides a monad for using delimited continuations, and in particular, I'd like to look at the pair of operations that are most common for the subject: shift and reset. A simplified version of their types look like the following:
reset :: (Prompt a -> CC a) -> CC a
shift :: Prompt b -> ((CC a -> CC b) -> CC b) -> CC a
reset b
creates a delimited scope, and hands b
a Prompt
that can be used to refer to that scope. shift p e
, captures the continuation inside the scope referred to by p
, and hands it to e
to use as it wishes. A simpler presentation would remove mention of the prompts, but they are actually essential in CC
to get the types to work out. Quite a lot of examples get by with only a single prompt.
There are some important properties that the above operators follow. Basically, both reset
and shift
contain control effects to certain regions. If we forget about mismatched prompts, then no control effects can escape from a reset
, nor can they escape from the body of a shift
. It is also the case that the captured continuation passed to the body of shift
is a pure function; it does not have any control effects. This is not true in CC
, because a p1
captured continuation can have p2
effects (and actually, there are lower level operators that can subvert these properties). But, this is at least the intended behavior of shift/reset
.
An alternate implementation
There is, however, a way of working with delimited continuations using just the ordinary Cont
type. My favorite such way is as follows:
reset :: Cont r r -> r
reset = (`runCont` id)
shift :: ((a -> r) -> r) -> Cont r a
shift = cont
The similarities between these types and the CC
counterparts should be clear, hopefully. One thing to note is that the first argument to Cont
is serving the same role as the type parameter of the prompt above; telling us what the result type of the continuation is for our delimited scope.
However, there is a huge difference between these types and the CC
version: these types statically ensure the 'important properties' I mentioned above. reset
obviously lets no control effects escape, because it is just returning a pure value. The continuation passed to the body of shift is obviously a pure function, and no control effects are allowed to escape from the body of shift, because the return type of the body is just r
. Many presentations of this version of delimited continuations mask this by re-injecting things into Cont
, but this is my favorite version because of how the types tell you all the relevant properties.
One can do the same construction with ContT
, and a similar thing occurs:
reset :: Monad m => ContT r m r -> m r
reset = (`runContT` return)
shift :: ((a -> m r) -> m r) -> ContT r m a
shift = ContT
It is still obvious where control effects can occur with shift
and reset
, at least excluding any control effects supported by m
(which is a way to have multiple prompts in this setup; except the prompt scoping is enforced statically---albeit with terrible de Bruijn indices---while in CC-delcont one could use prompts in invalid scopes). This delimiting of effects is achieved statically by moving in and out of the monad (transformer), and it seems to me to be a good fit for many shift/reset
examples, as they are sometimes known as the static delimited control operators.
Throw/catch confusion
Let's consider an example from the recent paper Extensible Effects. The idea is the following: we want to have a nondeterministic computation with exceptions. If any one of the nondeterministic possibilities fails, the whole computation should fail. However, at a certain point, we want to handle some of the exceptions, and the handled exceptions should not interfere with the computation working or not.
(I'm going to make my own nondeterminism class here, as MonadPlus
is overloaded to also cover exception-like things, so exceptions will eat non-determinism.)
{-# LANGUAGE FlexibleContexts #-}
-- show
import Control.Monad.Identity
import Control.Monad.Error
import Control.Monad.List
class Monad m => MonadChoice m where
choose :: [a] -> m a
-- /show
instance MonadChoice [] where choose = id
instance Monad m => MonadChoice (ListT m) where
choose = ListT . return
instance (Error e, MonadChoice m) => MonadChoice (ErrorT e m) where
choose = lift . choose
-- show
newtype TooBig = TooBig Integer deriving (Eq, Ord, Show, Read)
instance Error TooBig -- don't care
example :: (MonadError TooBig m) => m Integer -> m Integer
example m = do
v <- m
if v > 5
then throwError (TooBig v)
else return v
handle :: MonadError TooBig m => m Integer -> m Integer
handle m = m `catchError` \e ->
case e of
TooBig v | v <= 7 -> return v
_ -> throwError e
wrong1 :: Either TooBig [Integer]
wrong1 = runIdentity . runErrorT . runListT
. handle . example $ choose [2,3,5,7]
wrong2 :: [Either TooBig Integer]
wrong2 = runErrorT . handle . example $ choose [2,3,5,7,11]
-- /show
main = do print wrong1
print wrong2
The above program demonstrates that no single, global choice of effect interaction produces the desired results. In wrong1
, the failing 7 destroys all other computations, leaving it as the only case when we recover. In wrong2
, we get the local recovery, but the still failing 11 does not cause the rest of the computations to die.
The proposal in the paper seems to be to make catchError
behave in the way that we want, but leave its API the same as the mtl. However, I contend that this is actually a sub-optimal choice. The API of catchError
is based on the notion that it is an operation in some indeterminate error-supporting monad. But, if the algebraic effects and handlers work tells us anything (or at least, if I understand it well enough), it's that handlers are actually not operations of the monad. throw
is a generator of the algebra, but catch
is a handler, and should probably eliminate from the set of effects, much like reset
above. However, we can actually write such an operation using the mtl/transformers:
{-# LANGUAGE FlexibleContexts #-}
import Control.Monad.Identity
import Control.Monad.Error
import Control.Monad.List
class Monad m => MonadChoice m where
choose :: [a] -> m a
instance MonadChoice [] where choose = id
instance Monad m => MonadChoice (ListT m) where
choose = ListT . return
instance (Error e, MonadChoice m) => MonadChoice (ErrorT e m) where
choose = lift . choose
newtype TooBig = TooBig Integer deriving (Eq, Ord, Show, Read)
instance Error TooBig -- don't care
example :: MonadError TooBig m => m Integer -> m Integer
example m = do
v <- m
if v > 5
then throwError (TooBig v)
else return v
-- show
localCatch :: Monad m => ErrorT e m a -> (e -> m a) -> m a
localCatch m h = runErrorT m >>= \x -> case x of
Left e -> h e
Right a -> return a
handle :: MonadError TooBig m => ErrorT TooBig m Integer -> m Integer
handle m = m `localCatch` \e ->
case e of
TooBig v | v <= 7 -> return v
_ -> throwError e
right1 :: Either TooBig [Integer]
right1 = runIdentity . runErrorT . runListT
. handle . example $ choose [2,3,5,7]
right2 :: Either TooBig [Integer]
right2 = runIdentity . runErrorT . runListT
. handle . example $ choose [2,3,5,7,11]
-- /show
main = do print right1
print right2
The code is identical, except we use localCatch
instead of catchError
. However, the type of localCatch
makes clear that it delimits the scope in which (certain) error effects can occur, handling all of them. Our use of it in handle
rethrows some of the exceptions, but it is not necessary to do so; exceptions are actually reintroduced as an effect in the handler, but in the m
monad, so that we can make a later decision about how they should interact with the nondeterministic choice.
This is the purported confusing solution to the problem, using two exception transformers. And indeed, if we look at the final stack we used, it was ErrorT e (ListT (Error e))
. However, at no point were we obligated to think about that. The exception generators simply programmed to the MonadError e
specification, the first ErrorT e
was used to introduce a local exception scope in which we could recover without clobbering other effects, and the second was used when we finally observed the computation, when we did want errors to clobber the nondeterminism. And this is exactly what would have been going on with algebraic effects, where the local handler would pass through the choice effects, and reintroduce exceptions so that they could interact differently with choice than they did in the local scope.
If anything, this example seems to be pointing to the stock catchError
being a bad operator, at least for this use case. The more handler-like runErrorT
, or a combinator based on it, is appropriate, and gives effect scoping information in the types, much like shift/reset
.
Finally, note that the choice of runErrorT
in localCatch
was somewhat arbitrary. Algebraic effects allow programs to write to one error algebra, but be used with many handlers. This is also true here; programs written with throwError
incur a MonadError
constraint, but that constraint can be locally satisfied by any concrete instance thereof, and the choice of instance corresponds to a choice of handler for the error algebra. Choosing an entire stack of transformers corresponds to handling all effects simultaneously in a particular way, but handling only a portion of the effects can be done in the mtl, by running a particular transformer and choosing to pass the remaining obligations to the underlying monad.
Another example
It occurred to me when thinking about this that I'd used a similar technique while writing a piece of a compiler. It is an implementation of a Haskell-like language, and the relevant code is in the type class solving machinery. The idea is that we wish to replace the type class obligations we've collected with simpler versions, based on class and instance declarations we've seen. The core loop looks something like (simplified from the actual code):
dischargesBySupers :: (Alternative m, Discharge m)
=> Class -> [Class] -> m [Class]
dischargesByInstance :: (Alternative m, Discharge m)
=> Class -> m [Class]
entails :: (Alternative m, Discharge m) => [Class] -> Class -> m [Class]
entails cs ob = ob `dischargesBySupers` cs
<|> (dischargesByInstance ob >>= simplify cs)
simplify :: Discharge m => [Class] -> [Class] -> m [Class]
simpify cs obs = do
x <- for obs $ \ob ->
runMaybeT (entails cs ob) <&> fromMaybe (pure ob)
pure $ join x
dischargesBySupers
informs us if its first argument can be gotten by projection out of the classes in the list. dischargesByInstance
tells us if there is an instance for the given class, and what obligations it requires. Both of these checks can fail, hence the Alternative
. Then, entails
checks if the list of class obligations is sufficient to entail a particular obligation, and gives what obligations that would add, again potentially failing. simplify
reduces many obligations given a set of prerequisites. However, simplify
is not generally allowed to fail, as it is the point at which we call into the solver; so, it creates a local scope in which failure is permitted, but handles failure by yielding the original value.
Note, though, that enails
calls back into simplify
, to ensure that the additional obligations returned are themselves fully simplified. In this case, we are using simplify
in a context that can fail, but it still creates its own neseted effect scope. In general, there is no static determination on how many nested effect scopes are created, only static determination of which effects are handled in which scope.
Conclusion
The above is not the only example of where we use local, static effect scopes in the compiler. For instance, we use a local scope to track sharing information in parts of the unifier. However, we do this by writing the effectful code using a type class interface, MonadFoo
and 'handling' with a corresponding runFooT
. Doing so, one gets something that looks a lot like the algebra+handlers way of doing effects (to my untrained eyes, at least), even with the boring old mtl. Static effect scoping needn't preclude local effect scoping.
Bonus
I mentioned earlier that one could do multi-prompt continuations in the alternate style; here's how it looks:
import Control.Monad.Cont
-- show
reset :: Monad m => ContT r m r -> m r
reset = (`runContT` return)
shift :: ((a -> m r) -> m r) -> ContT r m a
shift = ContT
example1 :: MonadIO m => m ()
example1 = reset $ do
liftIO $ putStrLn "Example 1"
reset $ do
-- throw the continuation away
shift $ \_ -> return ()
liftIO $ putStrLn "Hi!"
example2 :: MonadIO m => m ()
example2 = reset $ do
liftIO $ putStrLn "Example 2"
reset $ do
-- capture to the outer reset this time
lift . shift $ \_ -> return ()
liftIO $ putStrLn "Bye!"
-- /show
main = example1 >> example2
For each lift
, we shift
out one more layer. This, of course, is not a fantastic API.