Haskell logo CIS 5520: Advanced Programming

Fall 2024

  • Home
  • Schedule
  • Homework
  • Resources
  • Software
  • Style guide
Note: this is the stubbed version of module Transformers. Try to figure out how to fill in all parts of this file marked undefined. CIS 5520 students should be able to access this code through github. Eventually, the completed version will be available.

Monad Transformers

> module Transformers where
> import Control.Monad (liftM,ap)
> import Data.Kind(Type)
> import State (State)              
> import qualified State as S
> import Data.Function ( (&) )

How do we use multiple monads at once?

Sometimes, you might need to both use a state transformer and signal errors. We've seen that individual monads can structure our code nicely, but what if we want to do more things at once? One solution is to build a combined monad from scratch, like we saw in the parsing lecture.

Another solution is to use the monad transformer library (called mtl) and build the combined monad from reusable components.

We'll demonstrate how that can work today, and discuss how that library is designed, using a running example of simple evaluators for a simple programming language.

Here is our simple language: only division expressions.

> data Expr = Val Int
>           | Div Expr Expr
>           deriving (Show)

Our first evaluator is unsafe. It could cause an error when we run it.

> eval            ::  Expr -> Int
> eval (Val n)   =  n
> eval (Div x y) =  eval x `div` eval y

Here are two terms that we will use as running examples.

> ok :: Expr
> ok  = (Val 1972 `Div` Val 2)
>       `Div` Val 23
> err :: Expr
> err = Val 2 `Div`
>       (Val 1 `Div`
>        (Val 2 `Div` Val 3))

The first evaluates properly and returns a valid answer, while the second fails with a divide-by-zero exception.

> -- >>> eval ok
> -- >>> eval err

This eval function is not very good because it can blow up with a divide-by-zero error and stop the whole evaluator. And, there is nothing to do about it: we can't catch the error.

Of course, one way to fix the problem is to detect the error and then continue with a default value (such as 0).

> evalDefault            ::  Expr -> Int
> evalDefault (Val n)   =  n
> evalDefault (Div x y) =
>   let m = evalDefault y in
>   if m == 0 then 0 else evalDefault x `div` m

But, no one likes this solution. It leads to buggy code.

Error Handling Via Exception Monads

Alternatively, we can use the Either type to treat the failure case more gently: a Left result, carrying an error message, means that an error happened somewhere, while a Right n result meant that evaluation succeeded yielding n. We saw how this worked in the EitherMonad exercise.

Try to use the Either monad (with do notation) to write a better exception- throwing evaluator, where the helper function errorS generates the error string.

> errorS :: Show a => a -> a -> String
> errorS y m = "Error dividing " ++ show y ++ " by " ++ show m
> -- | exception-throwing evaluator
> evalEx ::  Expr -> Either String Int
> evalEx (Val n)   = return n
> evalEx (Div x y) = undefined

When we call this evaluator, we'll format its result into a string using the following function. For future flexibility, this function is parameterized by a higher-order function used to display the result.

> showEx :: (a -> String) -> Either String a -> String
> showEx _ (Left m)  = "Raise: " ++ m
> showEx s (Right v) = "Result: " ++ s v

And this little function will call the evaluator above and then format its result.

> goEx :: Expr -> String
> goEx e = evalEx e 
>            & showEx show     -- the `&` operator is reverse application

This version should return Result: 42 for the ok term and "Raise: Error dividing..." for err.

> -- >>> goEx ok
> -- "Result: 42"
> -- >>> goEx err
> -- "Raise: Error dividing Val 1 by Div (Val 2) (Val 3)"

Counting Operations Using the State Monad

Next, let's stop being paranoid about errors and instead try to do some profiling. Let's imagine that the div operator is very expensive, and that we would like to count the number of divisions that are performed while evaluating a particular expression.

As you might imagine, our old friend the state monad is going to be just what we need here! The type of store that we'd like to use is just the count of number of division operations, and we can store that in an Int.

> type Store = Int

We'll need a way of incrementing the counter:

> tickProf :: State Store ()
> tickProf = do
>   x <- S.get      -- use get and put from the state monad
>   S.put (x + 1)

Now we can write a profiling evaluator, and observe it at work.

> evalSt           :: Expr -> State Store Int
> evalSt (Val n)   = return n
> evalSt (Div x y) = do
>   m <- evalSt x
>   n <- evalSt y
>   tickProf
>   return (m `div` n)

For flexibility later, we'll define a function that displays the result of the State monad.

> -- | Show the result of runState, parameterized by a function
> -- to show the value
> showSt :: (a -> String) -> (a, Store) -> String
> showSt f (v, cnt) = f v ++ ", count: " ++ show cnt

And put it all together.

> goSt :: Expr -> String
> goSt e = evalSt e 
>              & flip S.runState 0    -- The `&` operator is reverse application
>              & showSt show          -- It lets us create a short transformation pipeline 
> -- >>> goSt ok
> -- "42, count: 2"

But... alas! To get the profiling, we threw out the nifty error handling that we had put in earlier!!

> -- >>> goSt err
> -- divide by zero

Transformers: Making Monads Multitask

So, at the moment, it seems that monads can do many things, but only one thing at a time -- you can either use a monad to do the error- management plumbing or to do the state-manipulation plumbing, but not both at the same time. Is it too much ask for both? I guess we could write a mega-state-and-exception monad that supports the operations of both, but that doesn't sound fun! Especially since, if we later decide to add yet another feature, then we would have to make up yet another mega-monad.

So we will take a different approach, where we will keep wrapping -- or "decorating" -- monads with extra features, so that we can take a simple monad, and then add the Exception monad's features to it, and then add the State monad's features and so on.

The key to doing this is to define exception handling, state passing, etc., not as monads, but rather as type-level functions from monads to monads.

This will require a little more work up-front (most of which is done already in well-designed libraries), but after that we can add new features in a modular manner. For example, to get a mega state-and-exception monad, we will start with a dummy Identity monad, supply it to the StateT monad transformer (which yields state-passing monad) and pass the result to the ExceptT monad transformer, which yields the desired mega monad.

(Incidentally, if you are a Python programmer, the above may remind some of you of the [Decorator Design Pattern][2] and other [Python Decorators][3].)

Step 1: Describing Monads With Special Features

The first step to being able to compose monads is to define typeclasses that describe monads with particular features. For example, the notion of an exception monad is captured by the typeclass that describes monads that are also equipped with an appropriate throwError function.

> class Monad m => MonadError e m where
>   throwError :: e -> m a

This function takes an error value of type e (e.g. String for error messages). The result type is m a where a is polymorphic --- in otherwords, we can throw an error in any (monadic) context.

We can make Either s an instance of the above class like this:

> instance MonadError s (Either s) where
>   throwError :: s -> Either s a
>   throwError = Left

Now see what happens if you change Left to throwError in the evaluator evalEx above and remove the type signature. What is the new type of the evaluator that GHC infers?

Similarly, we can bottle the key operations of a state monad in a typeclass that describes monads equipped with extraction (get) and modification (put) functions of appropriate types.

> class Monad m => MonadState s m where
>   get      :: m s           
>   put      :: s -> m ()

We can then redefine the ticking operation to work for any state monad:

> tickMonadState :: MonadState Int m => m ()
> tickMonadState = do
>     (x :: Int) <- get
>     put (x + 1)

Naturally, we can make our State monad an instance of the above:

> instance MonadState s (State s) where
>   get :: State s s
>   get        = S.get
> 
>   put :: s -> State s ()
>   put        = S.put

Now go back and see what happens when you replace tickProf with tickMonadState in evalSt above and remove the type signature.

Step 2: Using Monads With Special Features

Armed with these two typeclasses, we can write our exception-throwing, step-counting evaluator quite easily:

> evalMega :: (MonadError String m, MonadState Int m) => Expr -> m Int
> evalMega (Val n)   = return n
> evalMega (Div x y) = do
>   n <- evalMega x
>   m <- evalMega y
>   if m == 0
>     then throwError $ errorS n m
>     else do
>       tickMonadState
>       return (n `div` m)

Note that it is simply the combination of the two evaluators from before -- we use the error handling from evalEx and the profiling from evalSt.

Meditate for a moment on the type of the above evaluator; note that it works with any monad that is both a exception- and a state- monad! (Also observe that if you remove this type annotation, GHC can infer it for you.)

Interlude: Creating MegaMonads

But where do we get monads that are both state-manipulating and exception-handling?

One answer is that we could just define one! Below is the start of the Mega monad that I alluded to above: it's a bit fiddly to complete these definitions, but you can generally use the types as your guide.

Next, finish the instances for Monad, MonadError String, and MonadState Int. Make sure that evalMega works with your monad.

> newtype Mega a = Mega { runMega :: Int -> Either String (a, Int) }
> instance Monad Mega where
>   return :: a -> Mega a
>   return x = undefined
>   (>>=) :: Mega a -> (a -> Mega b) -> Mega b
>   ma >>= fmb = undefined
> instance Applicative Mega where
>   pure = return
>   (<*>) = ap
> instance Functor Mega where
>   fmap = liftM
> instance MonadError String Mega where
>   throwError :: String -> Mega a
>   throwError str = undefined
> instance MonadState Int Mega where
>   get   = undefined
>   put x = undefined

Finally, once we have a Mega monad, we can run it and display the result.

> goMega :: Expr -> String
> goMega e = evalMega e 
>             & flip runMega 0   -- reverse order of arguments
>             & showMega
>   where
>     showMega :: Either String (Int, Store) -> String
>     showMega = showEx (showSt show)   -- use formatting functions from above
> -- >>> goMega ok
> -- "Result: 42, count: 2"
> -- >>> goMega err
> -- "Raise: Error dividing 1 by 0"

In the end, making your own mega-monad is a bit disappointing, since we've already defined the state- and exception-handling functionality separately.

A better answer is to build this monad (and others like it) piece by piece. We'll do this by defining some type level functions that will add state manipulation or exception handling to any pre-existing monad.

Step 3: Adding Features to Existing Monads

To add new features to existing monads, we use monad transformers -- type operators t that map a monad m to a new monad t m.

A Transformer For Exceptions

Consider the following datatype declaration:

> type ExceptT :: Type -> (Type -> Type) -> Type -> Type
> newtype ExceptT e m a = MkExc { runExceptT :: m (Either e a) }

Look closely at the kind of ExceptT above.

This type constructor takes a type e (the type of the error value, such as String), then an underlying monad m and then an argument a. It is a lot like Either e a except that we have added a new monad m in the middle.

If you look at the definition of ExceptT you'll see that this monad wraps the Either e a type.

If m is a monad, then we can make ExceptT a monad. Furthermore, the instance below looks a lot like the Monad instance for the Either type above. We just need to (a) work with the newtype (using MkExc and runExceptT) and (b) use return and >>= from the monad m to glue computations together.

> instance Monad m => Monad (ExceptT e m) where
>   return :: forall a. a -> ExceptT e m a
>   return x = MkExc (return (Right x) :: m (Either e a))
>   (>>=) :: ExceptT e m a -> (a -> ExceptT e m b) -> ExceptT e m b
>   p >>= f  = MkExc $ runExceptT p >>= (\ x -> case x of
>                                           Left e  -> return (Left e)
>                                           Right a -> runExceptT (f a))
> instance Monad m => Applicative (ExceptT e m) where
>    pure = return
>    (<*>) = ap
> instance Monad m => Functor (ExceptT e m) where
>    fmap = liftM

Next, we ensure that the transformer is also an exception monad by equipping it with throwError.

Compare this definition to that of MonadError e (Either e) above.

> instance Monad m => MonadError e (ExceptT e m) where
>   throwError :: e -> ExceptT e m a
>   throwError msg = MkExc (return (Left msg))

A Transformer For State

Next, we will build a transformer for the state monad, following more or less the same recipe as we did for exceptions. Here is the definition for the transformer (preceded by its kind):

> type StateT :: Type -> (Type -> Type) -> Type -> Type
> newtype StateT s m a =  MkStateT { runStateT :: s -> m (a, s) }

The enhanced monad is a variant of the ordinary state monad where a starting store is mapped to a computation in the monad m that returns both a result of type a and a new store.

(Aside: note that the monad transformer is not defined like this:

newtype StateT s m a = MkStateT { runStateT :: m (s -> (a, s)) }

That is, it is not an m computation yielding a store transformation. Why is this not what we want?)

Next, we declare that the transformer's output is a monad. Again, compare the definitions below to that of the State monad.

> instance Monad m => Monad (StateT s m) where
>   return :: a -> StateT s m a
>   return x = MkStateT $ \s -> return (x,s)
>   (>>=) :: StateT s m a -> (a -> StateT s m b) -> StateT s m b
>   p >>= f = MkStateT $ \s -> do (r,s') <- runStateT p s
>                                 runStateT (f r) s'
> instance Monad m => Applicative (StateT s m) where
>   pure  = return
>   (<*>) = ap
> instance Monad m => Functor (StateT s m) where
>   fmap  = liftM

And finally we declare that the transformer is a state monad by equipping it with the operations from MonadState Int. You fill in these definitions.

> instance Monad m => MonadState s (StateT s m) where
>   get :: StateT s m s
>   get = MkStateT getIt
>     where getIt :: s -> m (s, s)
>           getIt s = undefined
>   put :: s -> StateT s m ()
>   put s = MkStateT putIt
>     where putIt :: s -> m ((), s)
>           putIt _ = undefined

Where are we now?

  • If m is a monad, then StateT s m is a state monad (i.e. an instance of MonadState)
  • If m is a monad, then ExceptT e m is an error monad (i.e. an instance of MonadError)

But, what about StateT s (ExceptT e m)? We know it is a state monad by the above. But, we'd also like it to be an error monad.

In other words, we need the following "pass through" properties to hold:

  • If m is a state monad, then ExceptT e m is still a state monad
  • If m is an error monad, then StateT Int m is still an error monad

We can do this in a generic way.

Step 4: Preserving Old Features of Monads

Of course, we must make sure that the original features of the monads are not lost in the transformed monads. The key ingredient of a transformer is that it must have a function lift that takes an m operation and turns it into a t m operation. For example, the State monad supports get and set. We would like to be able to lift get and set so that they work for ExceptT e (State s) too.

In general, this function will allow us to transfer operations from the old monad into the transformed monad: any operation on the input monad m can be directly lifted into the transformed monad, and so the transformation preserves all the operations of the original monad.

> class MonadTrans (t :: (Type -> Type) -> Type -> Type) where   -- from Control.Monad.Trans (among other places)
>   lift :: Monad m => m a -> t m a

It is easy to formally state that ExceptT e is a bona-fide transformer by making it an instance of the MonadTrans class:

> instance MonadTrans (ExceptT e) where
>   lift :: Monad m => m a -> ExceptT e m a
>   -- Recall the type of MkExc
>   -- MkExc :: m (Either e a) -> ExceptT e m a
>   lift = MkExc . lift_ where
>     lift_  :: (Monad m) => m a -> m (Either e a)
>     lift_ mt = Right <$> mt

Similarly, for the state monad transformer:

> instance MonadTrans (StateT s) where
>   lift :: Monad m => m a -> StateT s m a
>   -- Recall the type of MkStateT
>   -- MkStateT  :: (s -> m (a,s)) -> StateT s m a
>   lift ma = MkStateT $ \s -> do r <- ma
>                                 return (r,s)

Using lift, we can ensure that, if a monad was already an "error" monad, then the result of the state transformer is too:

> instance MonadError e m => MonadError e (StateT s m) where
>   throwError :: e -> StateT s m a
>   throwError = lift . throwError

Similarly, if a monad was already a state-manipulating monad, then the result of the exception-transformer is also a state-manipulating monad:

> instance MonadState s m => MonadState s (ExceptT e m) where
>   get :: ExceptT e m s
>   get = lift get
>   put :: s -> ExceptT e m ()
>   put = lift . put

Step 5: Whew! Put It Together and Run

Finally, we can put all the pieces together and run the transformers. We can also order the transformations differently (which can have different consequences on the output, as we will see).

Here are our two evaluators, distingiushed by the order of the transformers that we use. Note that the code is the same in each case, but the MonadState and MonadError constraints in evalMega are resolved differently.

> evalExSt :: Expr -> StateT Int (Either String) Int
> evalExSt = evalMega
> evalStEx :: Expr -> ExceptT String (State Store) Int
> evalStEx = evalMega

And here are the two functions that format their results, defined with the formatting functions from above.

> showExSt :: Either String (Int, Int) -> String
> showExSt = showEx (showSt show)
> showStEx :: (Either String Int, Int) -> String
> showStEx = showSt (showEx show)

We can run these interpreters and display their results as follows:

> goExSt :: Expr -> String
> goExSt e = (evalExSt e :: StateT Int (Either String) Int)
>              & flip runStateT 0            
>              & showExSt                    
> goStEx :: Expr -> String
> goStEx e = (evalStEx e :: ExceptT String (State Store) Int)
>              & runExceptT
>              & flip S.runState 0
>              & showStEx

When everything works, we get the same answer. But look what happens if we try to divide by zero!

> -- >>> goExSt ok
> -- "Result: 42, count: 2"
> -- >>> goExSt err
> -- "Raise: Error dividing 1 by 0"
> -- >>> goStEx ok
> -- "Result: 42, count: 2"
> -- >>> goStEx err
> -- "Raise: Error dividing 1 by 0, count: 1"

Step 6: Getting the original monads back

It seems a little silly that the monad definitions for State and for StateT share so much code. What if we want a monad that is only a state monad, and not layered on top of something else?

As alluded to above, we can define an Identity monad to use under any other.

> newtype Id a = MkId a deriving Show
> instance Monad Id where
>   return x = undefined
>   (MkId p) >>= f = undefined
> instance Applicative Id where
>   pure  = return
>   (<*>) = ap
> instance Functor Id where
>   fmap  = liftM
> type State2 s  = StateT  s Id   -- isomorphic to State s
> type Either2 s = ExceptT s Id   -- isomorphic to Either s

Step 7: Using the library in your code

The mtl library wraps contains the definitions of StateT, MonadState, ExceptT, MonadError, and the instances described above.

The file MtlExample demonstrates how to use this library using the examples in this file.

Design adapted from Minimalistic Design | Powered by Pandoc and Hakyll