Practical Monad Transformers
This post will attempt to convey some real-life examples and tricks, which may serve as a quick and dirty primer to monad transformers. In any real-life software written in Haskell or related languages, Monad Transformers are priceless if you want to take advantage of the various non-IO monads available.
This post will draw heavily from my recent experience while implementing a blockchain protocol in Haskell.
For a more in-depth approach, I would recommend the chapter on Monad Transformers in Real World Haskell.
To run the examples in this post, refer to the section at the end of this post. You will also find a quick primer on monads at the end, in a section named monads.
Introduction to Monad Transformers
Consider the scenario where you are writing a software which:
- Interacts with the outside world (network, files, console)
- Contains a state which modifies the interactions
One would easily note that the State monad would be a useful monad to use here. But it does not allow IO actions, which seem to be necessary here too. Monad transformers allow you to combine multiple monad and use them together in the same do
block.
Most of these monads transformers are available inside the mtl
or the transformers
packages.
# If you're using the mtl package
# import Control.Monad.State.Lazy
# If you're using the transformers package
# We will use this in our examples.
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.State.Lazy
fxnWithStateAndIO :: StateT Int IO Int
fxnWithStateAndIO = do
st <- get
lift $ putStrLn ("State is " ++ show st)
return st
Ignore the lift
for now, and observe how, in a single do
block, we are able to use the get
function (which requires the State monad) and the putStrLn
function which requires the IO monad. One could use any other monad in place of IO as well.
Basically, StateT s m a
has three type parameters:
- s: The type of the state being carried
- m: The underlying monad (IO, in the above example)
- a: The type of the value returned from the computation
For now, simply remember that when inside a Monad transformer’s do
block, you can access functions of the top level monad (State here) as well as the underlying monad (IO here).
Running these monadic computations
To get the result from a term of type StateT s IO a
, you need to use runStateT yourFxn initState
. This should be familiar if you have used the state monad.
To run the above in ghci
, put it into a file, load it with ghci <file>.hs
(ensure you have the transformers
package available). The following command will run the computation:
Here, 5
is the initial state provided to the computation.
Lift?
Remember how we used a lift
to run the putStrLn
command? lift
is a function, which lets you, in our above example, convert an IO ()
to a StateT Int IO ()
. Think of it as, lifting an operation in one monad, to a monad which subsumes the first monad. If you look at StateT Int IO a
, you can see that it subsumes IO. Thus, lift
is suited for running putStrLn
, readLn
or any other IO action in this example.
Try to understand the following example:
import Control.Monad (when)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.State.Lazy
testFxn :: StateT Int Maybe Bool
testFxn = do
v <- get
x <- lift $ Just True
when x (put 2)
return False
The resultant state is 2, and the returned value is False. The when
function is just a fun addition, it will execute its second argument if the first argument is true. Otherwise, it will result in a value of type StateT Int Maybe ()
(Unit of this monad) in this case.
Combining multiple transformers
The above is all well and good, but what if you want to use three monads instead of two? Let us say that we want to use the Except
monad as well. Which is to say, we need a function which can:
- Throw custom exceptions
- Maintain a state
- Interact with the outside world
This is easily possible in the realm of monad transformers. Observe the following:
ExceptT
monad transformer is available in thetransformers
package.ExceptT e m a
is its signature. Here,m
must be a monad.StateT s m
is a monad (how? this is off topic for this article).StateT s IO
is a monad.- Using the above,
ExceptT e (StateT s IO)
is a monad. ExceptT e (StateT s IO) a
is a computation in theExceptT e (StateT s IO)
monad, which returns a monadic/wrapped value of typea
.
Now we have, what we can think of, as a stack of monads. Monad transformers make such a stack possible. You can pile up as many monads as possible.
Lifting when you have multiple monads
This is something that often confuses people. Remember the lift
function? Let us rethink it.
Let us assume we have a monad stack like this (disregard the return value, and note that monad_d
is not a transformer):
What if you have a computation of the type monad_d ret
? How would you execute it inside a do block of the type MSTACKTYPE x
?
Think of lift
as a function, which lifts a computation in a lower monad to a computation in a higher monad (lower and higher refer to the position in the monad transformer stack). Thus, a computation of the type monad_d ret
can be converted to a computation of type MSTACKTYPE ret
using the function lift $ lift $ lift
(three times, because three layers have to be crossed).
Running a computation which has multiple monad transformers
Let us try to run the following computation:
import Control.Monad (when)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.State.Lazy
import Control.Monad.Trans.Except
realLifeFunction :: Int -> ExceptT String (StateT Int IO) Int
realLifeFunction input = do
x <- lift $ get
lift $ lift $ putStrLn $ "The original state is" ++ (show x)
lift $ put input
return x
This is slightly complicated, but you can easily do this with some thought and concentration :) We run each layer of the monad stack one by one, taking care to pass the proper arguments.
This is a computation of type IO Int
. The value will be printed when this IO computation is executed.
Abstraction
This section will focus on some common abstraction patterns seen in programs which use monad transformers. The subsections appear long-ish, but that’s because they contain repeated code. Please read the comments in the codes for the complete explanation.
Monad typeclass
What if you have a computation running in the StateT Int (ExceptT String IO)
monad, but, it needs to due a purely state operation? (An operation which does not do any operation in the Except or IO monad). Can we make this explicit at the type level?
It turns out that we can. We list three possible ways to do this, where the final one is the solution.
mainFxn :: StateT Int (ExceptT String IO) Bool
mainFxn = do
lift $ lift $ putStrLn "Running function"
doubleTheState
st <- get
let ret = if (st > 3) then True else False
return ret
-- This will NOT work, because 'StateT Int ()' cannot be run
-- inside a 'StateT Int (ExceptT String IO) Bool'
doubleTheState' :: State Int ()
doubleTheState' = do
x <- get
put (2*x)
return ()
-- This will work, but our type is superfluous.
-- We do not want the extra monads to stay in scope.
doubleTheState'' :: StateT Int (ExceptT String IO) ()
doubleTheState'' = do
x <- get
put (2*x)
return ()
-- This WILL WORK.
doubleTheState :: Monad m => StateT Int m ()
doubleTheState = do
x <- get
put (2*x)
return ()
Using lift to abstract
The above section showed how you can abstract out the middle layers of the monad stack. What if you want to execute an action in a monad which is somewhere in the middle of the stack, without bothering about any other monads?
We can use a combination of lift
and Monad
typeclass.
-- NOTE: The monads order is flipped from the previous section.
mainFxn :: ExceptT Int (StateT String IO) Bool
mainFxn = do
-- Functions in the monad at the bottom of the stack (IO here)
-- can be run without any changes.
lift $ lift $ putStrLn "Running function"
lift doubleTheState -- We can use lift to run this StateT computation
st <- lift get -- This requires a lift since StateT is buried one level deep.
let ret = if (st > 3) then True else False
return ret
-- Functions using monads in the middle of the stack need to use the
-- (Monad m) type constraint.
doubleTheState :: Monad m => StateT Int m ()
doubleTheState = do
x <- get
put (2*x)
return ()
MonadIO
This is often the most used abstraction in codes heavy on IO. Imagine if you have to do something like doubleTheState
in the section on Monad typeclass, but you also need to do IO in that function. Basically, what if you want to do IO without bothering about other enclosing monads?
MonadIO is a type constraint which is satisfied by all monads which contain IO somewhere in their monad stack. Thus, StateT Int (ExceptT String IO)
satisfies MonadIO, but ExceptT String (State Int)
does not.
This package also provides a helper function liftIO
, which applies however many lift s
are necessary to execute an IO action in the current monad.
All this is best explained through examples.
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Except
import Control.Monad.Trans.State.Lazy
-- liftIO allows executing IO operations
-- inside any monad which satisfies MonadIO.
printer :: MonadIO m => Int -> m Int
printer input = do
liftIO $ print input -- liftIO can do IO actions
return $ input + 1
complexPrinter :: MonadIO m => ExceptT String (StateT Int m) ()
complexPrinter = do
x <- lift get
printer x -- This can be run normally
liftIO $ putStrLn "This works normally"
return ()
Note that with MonadIO, you can write very abstract functions which can be used in other monads without any need of liftIO
(for instance, our printer
function here).
Appendix
Monads
As a quick review, monads are a functional abstraction, developed to help write imperative-style code in Haskell. If you are familiar with monads, feel free to skip this section.
Some of the things that a monad does are:
- Enforces ordering of statements
- Provides a syntax sugar for carrying a state across functional calls
- Easy short-circuit failure in program blocks which may throw an error
An example of such a monadic code is:
aFxnWhichFails :: Maybe Int
aFxnWhichFails = do
goodValue <- Just 1
thisWillShortCircuit <- Nothing
thisStatementWillNotRun <- Just 1
return $ Just 2
Here, we are executing inside the Maybe
monad. The execution breaks-out when it encounters a Nothing
value. Similarly, each monad implements a failure case, and a success case.
Some available monads are:
- Maybe
- Either
- Reader
- State
- Writer
- IO
IO is a special monad. It basically treats the whole program state as its underlying state, and lets you mutate it. Thus, all impure actions happen inside the IO monad. Printing values, reading values from console, network calls, reading or writing files, all these actions mutate the global state (repeating any of these actions twice may not show the same behaviour). Thus, all these actions are executed inside the IO monad.
How to run the examples
The examples given in this post can be run in any recent ghci
easily. I prefer to run them using stack ghci --package transformers
. You can also run them by installing the package transformers
with cabal, and then using ghci
. The examples should also work if placed in a file and run with runhaskell
like a regular haskell file.
Conversions between types
One conversion that we have seen till now is runStateT
, which converts a computation in a monad transformer to a computation of the underlying monad.
There are various other such conversions, like evalStateT
, execStateT
, mapStateT
, withStateT
, and similar ones for other monad transformers. You can look them up on hackage, in the documentation of the package. For instance, the documentation of StateT is available here.
Performance
Sadly, the performance of monad transformers is not very good. There can be a significant performance hit at times, but it should be noted that most scenarios will have an underlying IO call to the network / file-system, which would often be the bottleneck. When it is not so, and the bottleneck is a non-IO computation refer to the abstraction section and convert your computation to a small monadic stack or a pure function.
The following links contain more information, and may be helpful if you ever run into performance issues due to monad transformers, although it is quite unlikely.