Monad Transformers
Monads support composition in context. Another question to ask is, can we compose monads? In other words, can we combine monads together?
Consider the example of finding the length of the path between two connected neighbours in a directed graph, except that we have each node connected to at most one edge. The way we might solve this problem is, once again, via DFS (which in this case is the same as BFS), except that our graph is now of type [(Node, Node)]
and our function returns the length of the path instead of a Bool
value describing whether the path exists:
type Node = Int
type Graph = [(Node, Node)]
dfs :: Node -> Node -> Graph -> Maybe Int
dfs src dst gph = aux src [] gph where
aux :: Node -> [Node] -> Graph -> Maybe Int
aux current visited gph'
| arrived = return 0
| alreadyVisited = Nothing
| otherwise = do
n <- lookup current gph
(+1) <$> aux n (current : visited) gph'
where arrived = current == dst
alreadyVisited = current `elem` visited
Notice that just like our previous definition of dfs
, all our functions such as dfs
and lookup
involve some environment which we need to pass around! Let us try changing everything of type Graph -> Maybe Int
to Reader Graph (Maybe Int)
and modify our environment to no longer receive the gph
argument:
type Node = Int
type Graph = [(Node, Node)]
dfs :: Node -> Node -> Reader Graph (Maybe Int)
dfs src dst = aux src [] where
aux :: Node -> [Node] -> Reader Graph (Maybe Int)
aux current visited
| arrived = return 0
| alreadyVisited = Nothing
| otherwise = do
n <- lookup current
(+1) <$> aux n (current : visited)
where arrived = current == dst
alreadyVisited = current `elem` visited
Unfortunately, our code doesn't type check. This is because now our do
block performs the monadic operations based on the definition of Reader
, not on Maybe
! As such, we may need significant rewrites to our function to introduce the Reader
monad to our Maybe
computation.
Enriching the Maybe
Monad
Is there a better way? Yes! Let us try defining a new monad ReaderMaybe
that essentially acts as both the Reader
and the Maybe
monads!
newtype ReaderMaybe env a = ReaderMaybe { runReaderMaybe :: Reader env (Maybe a) }
instance Functor (ReaderMaybe env) where
fmap :: (a -> b) -> ReaderMaybe env a -> ReaderMaybe env b
fmap f (ReaderMaybe ls) = ReaderMaybe $ fmap (fmap f) ls
instance Applicative (ReaderMaybe env) where
pure :: a -> ReaderMaybe env a
pure = ReaderMaybe . pure . pure
(<*>) :: ReaderMaybe env (a -> b) -> ReaderMaybe env a -> ReaderMaybe env b
(ReaderMaybe f) <*> (ReaderMaybe x) = ReaderMaybe $ do
maybe_f <- f
case maybe_f of
Nothing -> return Nothing
Just f' -> do
maybe_x <- x
case maybe_x of
Nothing -> return Nothing
Just x' -> return $ Just (f' x')
instance Monad (ReaderMaybe env) where
return :: a -> ReaderMaybe env a
return = pure
(>>=) :: ReaderMaybe env a -> (a -> ReaderMaybe env b) -> ReaderMaybe env b
(ReaderMaybe ls) >>= f = ReaderMaybe $ do
m <- ls
case m of
Just x -> runReaderMaybe $ f x
Nothing -> return Nothing
All of these methods are tedious to define, however are somewhat straightforward. In particular, it relies on do
notation on Reader
s to extract out the Maybe
values, and performs the usual Maybe
methods to compose them.
The result is that we can now make use of this ReaderMaybe
monad in our dfs
function:
dfs :: Node -> Node -> Graph -> Maybe Int
dfs src dst = runReaderMaybe (aux src []) where
aux :: Node -> [Node] -> ReaderMaybe Graph Int
aux current visited
| arrived = return 0
| alreadyVisited = ReaderMaybe $ return Nothing
| otherwise = do
n <- ReaderMaybe $ lookup current
(+1) <$> aux n (current : visited)
where arrived = current == dst
alreadyVisited = current `elem` visited
There are several points worthy of note in our new implementation:
- Most of this definition is the same as our original definition that works on the
Maybe
monad - Because the
aux
function returns aReaderMaybe
term which wraps the actualReader
function, we writerunReaderMaybe (aux src [])
to expose the actualReader Graph (Maybe Int)
function - In the
alreadyVisited
case, we cannot writealreadyVisited = Nothing
sinceNothing
is not of the typeReaderMaybe Graph Int
; we also cannot just writereturn Nothing
since that has typeReaderMaybe env (Maybe a)
. As such, we have to usereturn @(Reader Graph) Nothing
, then wrap it in theReaderMaybe
constructor - Similar to (3), instead of
lookup current
, we have to wrap it around theReaderMaybe
constructor so that instead of having typeReader Graph (Maybe Int)
,ReaderMaybe $ lookup current
will have typeReaderMaybe Graph Int
, which is the correct type to have.
When converting the original implementation based on Maybe
into the new implementation based on ReaderMaybe Graph Int
, one tip is to leave the implementation the same and just change the type signature of the functions to use ReaderMaybe Graph Int
instead of Graph -> Maybe Int
, then make use of typing information to correct the types in the program; in other words, "let the types guide your programming", like we have done in Chapter 2 (Types)! Furthermore, we are generally assured that everything works as expected because monads behave in the most obvious way!
Just like that, we are able to compose the Reader
monad with the Maybe
monad! Running dfs
works exactly as we'd expect:
ghci> my_map = [(1, 2), (2, 3), (3, 1)]
ghci> dfs 1 4 my_map
Nothing
ghci> dfs 1 2 my_map
Just 1
ghci> dfs 2 1 my_map
Just 2
Now, what if we wanted to enrich the Maybe
monad with other notions of computation, such as []
, IO
etc? Suppose we follow the same procedure of enriching Maybe
with Reader
, but instead by enriching it with IO
, giving us a new monad IOMaybe a
which represents IO (Maybe a)
:
newtype IOMaybe a = IOMaybe { runIOMaybe :: IO (Maybe a) }
instance Functor IOMaybe where
fmap :: (a -> b) -> IOMaybe a -> IOMaybe b
fmap f (IOMaybe io) = IOMaybe (fmap (fmap f) io)
instance Applicative IOMaybe where
pure :: a -> IOMaybe a
pure = IOMaybe . pure . pure
(<*>) :: IOMaybe (a -> b) -> IOMaybe a -> IOMaybe b
(IOMaybe f) <*> (IOMaybe x) = IOMaybe $ do
maybe_f <- f
case maybe_f of
Nothing -> return Nothing
Just f' -> do
maybe_x <- x
case maybe_x of
Nothing -> return Nothing
Just x' -> return $ Just (f' x')
instance Monad IOMaybe where
return :: a -> IOMaybe a
return = pure
(>>=) :: IOMaybe a -> (a -> IOMaybe b) -> IOMaybe b
(IOMaybe m) >>= f = IOMaybe $ do
maybe_m <- m
case maybe_m of
Just x -> runIOMaybe $ f x
Nothing -> return Nothing
There are several things worth thinking about. Firstly, so far, it appears that we have to re-create new instances for every notion of computation we want to enrich Maybe
with. Secondly, you might realise that absolutely nothing about the definition of the instances care about the enriching monad. All of the definitions in the methods for ReaderMaybe
and IOMaybe
do not mention any Reader
-specific or IO
-specific functions. Instead, they all rely on their respective monad binds! Therefore, we can abstract these into a monad transformer.
Monad Transformers
A monad transformer MonadT m a
enriches Monad
with m
. For example, the MaybeT m a
monad transformer enriches Maybe
with m
. Therefore, our ReaderMaybe
and IOMaybe
monads can be represented exactly as MaybeT (Reader env)
and MaybeT IO
! The definition of MaybeT
is virtually the exact same as the definitions of ReaderMaybe
and IOMaybe
, except that we do not refer to Reader
or IO
, and leave them as m
:
newtype MaybeT m a = MaybeT { runMaybeT :: m (Maybe a) }
instance (Functor m) => Functor (MaybeT m) where
fmap f (MaybeT x) = MaybeT $ fmap (fmap f) x
instance (Functor m, Monad m) => Applicative (MaybeT m) where
pure = MaybeT . return . Just
mf <*> mx = MaybeT $ do
mb_f <- runMaybeT mf
case mb_f of
Nothing -> return Nothing
Just f -> do
mb_x <- runMaybeT mx
case mb_x of
Nothing -> return Nothing
Just x -> return (Just (f x))
instance (Monad m) => Monad (MaybeT m) where
return = MaybeT . return . Just
x >>= f = MaybeT $ do
v <- runMaybeT x
case v of
Nothing -> return Nothing
Just y -> runMaybeT (f y)
With this Maybe
monad transformer, we can rewrite our definition of dfs
by replacing ReaderMaybe Graph Int
with MaybeT (Reader Graph) Int
!
dfs :: Node -> Node -> Graph -> Maybe Int
dfs src dst = runMaybeT (aux src []) where
aux :: Node -> [Node] -> MaybeT (Reader Graph) Int
aux current visited
| arrived = return 0
| alreadyVisited = MaybeT $ return Nothing
| otherwise = do
n <- MaybeT $ lookup current
(+1) <$> aux n (current : visited)
where arrived = current == dst
alreadyVisited = current `elem` visited
And now with the MaybeT
monad transformer, we can enrich the Maybe
monad with any other monad we want without having to redefine new types and new type class instances for each of the monads we are enriching Maybe
with!
Monad Transformer Library
Because monads are so common in programming, the common monads already have their own monad transformers, and these are defined in the transformers
and mtl
libraries. If you want to use these commonly used monad transformers, just download the dependencies and import
the libraries into your programs! But... how do we do that?
Build Tools and Package Managers
Most production programming languages have a package manager and build tool, and Haskell is no different. In fact, Haskell has several package managers and build tools you can use. Two of the main competing ones are cabal
and stack
, both of which can be installed via GHCup. For our purposes, we shall just use cabal
since it is slightly simpler to use; most modern versions are generally fine, but for us, we shall use (at least) cabal-3.10.3
.
Project Initialization
Using cabal
is very simple. First, to create a new Haskell project, create an empty directory and run cabal init
(>
is the shell prompt of the terminal, do not enter >
as part of the command)
> mkdir my-project
> cd my-project
> cabal init
Then, cabal
will take you through a series of questions to initialize the project. Some notable options are:
- Executables are programs that can be executed; libraries are code that other Haskell users can import. For us, choose to build an executable
- The main module of the executable should be
Main.hs
. TheMain.lhs
option is for writing literate Haskell programs. You can use that as well, although for us, it is significantly easier to just useMain.hs
and write plain Haskell programs. - The language for our executable should be GHC2021, giving us as many of the latest features as we can have without having to include them as language extensions.
The result of running cabal init
is that your project directory has been initialized with several parts:
- The
app
directory (or whatever name you have chosen) stores the source code of your program my-project.cabal
is the specification of your project.
Project Configuration
Let us investigate what is in my-project.cabal
(some comments and fields omitted for concision):
cabal-version: 3.0
-- ...
common warnings
ghc-options: -Wall
executable my-project
import: warnings
main-is: Main.hs
-- Modules included in this executable, other than Main.
-- other-modules:
-- LANGUAGE extensions used by modules in this package.
-- other-extensions:
-- Other library packages from which modules are imported.
build-depends: base ^>=4.17.2.1
-- Directories containing source files.
hs-source-dirs: app
-- Base language which the package is written in.
default-language: GHC2021
The executable my-project
clause describes some of the specifications of our project. In particular, the build-depends
field describes any external dependencies we wish to include. These dependencies can be automatically pulled from Hackage by cabal
, as long as we specify the name, and optionally the version, of the package. For example, we want the Control.Monad.Trans.Maybe
module in transformers
library. Hence, to include the transformers
library to have access to monad transformers, just include transformers
in build-depends
.
-- ...
executable my-project
-- ...
-- Other library packages from which modules are imported.
build-depends: base ^>=4.17.2.1
, transformers
-- ...
Then, run cabal install
to install all our dependencies!
> cabal install
/path/to/my-project-0.1.0.0.tar.gz
Resolving dependencies...
Symlinking 'my-project' to '/path/to/.local/bin/my-project'
And that's all! Just like that, we now have access to transformers
functions, data types, classes and methods!
Writing the Program
Let us try creating a simple executable program in our project. First, we create our simple graph library. Right now, our project directory looks like this:
my-project/
├─ my-project.cabal
├─ app/
│ └─ Main.hs
└─ ...
Let us create a simple graph library by creating a file my-project/app/Data/Graph.hs
, therefore our directory structure becomes:
my-project/
├─ my-project.cabal
├─ app/
│ ├─ Main.hs
│ └─ Data/
│ └─ Graph.hs
└─ ...
This creates a new module called Data.Graph
. We must include this in our cabal
file so that cabal
knows to compile it as well. Head back to my-project.cabal
, and include Data.Graph
in the other-modules
field:
-- ...
executable my-project
-- ...
-- Modules included in this executable, other than Main.
other-modules: Data.Graph
-- ...
Now, open Graph.hs
and write some code! In particular:
- Declare the name of the module. In this case, the module is called
Data.Graph
because it is in theData
directory and the file name isGraph.hs
. - Import the
Control.Monad.Trans.Maybe
module to have access toMaybeT
, and theControl.Monad.Trans.Reader
monad to have access to theReader
monad. - Define our
dfs
function.
module Data.Graph where
import Control.Monad.Trans.Maybe
import Control.Monad.Trans.Reader
type Graph = [(Node, Node)]
type Node = Int
type GraphProcessor = MaybeT (Reader Graph) Int
dfs :: Node -> Node -> Graph -> Maybe Int
dfs src dst = runReader $ runMaybeT (aux src []) where
aux :: Node -> [Node] -> GraphProcessor
aux current visited
| arrived = return 0
| alreadyVisited = MaybeT $ return Nothing
| otherwise = do
n <- MaybeT $ reader $ lookup current
(+1) <$> aux n (current : visited)
where arrived = current == dst
alreadyVisited = current `elem` visited
Note that our Reader
monad shown in the previous chapter is quite different to the one defined in transformers
. In fact, Reader env a
is actually defined as ReaderT env Identity a
. This is because it is generally quite uncommon to use the Reader
monad by itself, since what it represents is just a plain function. The ReaderT
monad transformer is defined as such:
newtype ReaderT r m a = ReaderT { runReaderT :: r -> m a }
type Reader env a = ReaderT env Identity a
And the Identity
monad is completely uninteresting:
newtype Identity a = Identity { runIdentity :: a }
As such, the transformers
library exposes some helper functions to make working with the plain Reader
monad easier; for example, the runReader
function extracts the enclosed function from a ReaderT
, and the reader
function transforms a function into a ReaderT
.
We are done with our Graph
library. Now, open app/Main.hs
and write the following to see our dfs
function in action (note that print
is defined as putStrLn . show
)!
module Main where
import Data.Graph
myGraph :: Graph
myGraph = [(1, 2), (2, 3), (3, 1), (4, 5)]
main :: IO ()
main = do
print $ dfs 1 2 myGraph
print $ dfs 1 5 myGraph
We are done with developing our simple application! Compiling and running our program is simple with the help of build tools like cabal
. In the terminal, just enter cabal run
to compile the program (if changes have been made) and execute it!
> cabal run
Just 1
Nothing