Monad Transformers and Static Effect Scoping

As of March 2020, School of Haskell has been switched to read-only mode.

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.