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 = Inttype Graph = [(Node, Node)]dfs :: Node -> Node -> Graph -> Maybe Intdfs 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` visitedNotice 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 = Inttype 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` visitedUnfortunately, 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 NothingAll of these methods are tedious to define, however are somewhat straightforward. In particular, it relies on do notation on Readers 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 Intdfs 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` visitedThere 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
Maybemonad - Because the
auxfunction returns aReaderMaybeterm which wraps the actualReaderfunction, we writerunReaderMaybe (aux src [])to expose the actualReader Graph (Maybe Int)function - In the
alreadyVisitedcase, we cannot writealreadyVisited = NothingsinceNothingis not of the typeReaderMaybe Graph Int; we also cannot just writereturn Nothingsince that has typeReaderMaybe env (Maybe a). As such, we have to usereturn @(Reader Graph) Nothing, then wrap it in theReaderMaybeconstructor - Similar to (3), instead of
lookup current, we have to wrap it around theReaderMaybeconstructor so that instead of having typeReader Graph (Maybe Int),ReaderMaybe $ lookup currentwill 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_mapNothingghci> dfs 1 2 my_mapJust 1ghci> dfs 2 1 my_mapJust 2Now, 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 NothingThere 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 Intdfs 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` visitedAnd 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 initThen, 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.lhsoption is for writing literate Haskell programs. You can use that as well, although for us, it is significantly easier to just useMain.hsand 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
appdirectory (or whatever name you have chosen) stores the source code of your program my-project.cabalis 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: -Wallexecutable 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: GHC2021The 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.gzResolving 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.Graphbecause it is in theDatadirectory and the file name isGraph.hs. - Import the
Control.Monad.Trans.Maybemodule to have access toMaybeT, and theControl.Monad.Trans.Readermonad to have access to theReadermonad. - Define our
dfsfunction.
module Data.Graph where
import Control.Monad.Trans.Maybeimport Control.Monad.Trans.Reader
type Graph = [(Node, Node)]type Node = Int
type GraphProcessor = MaybeT (Reader Graph) Int
dfs :: Node -> Node -> Graph -> Maybe Intdfs 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` visitedNote 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 aAnd 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 :: GraphmyGraph = [(1, 2), (2, 3), (3, 1), (4, 5)]
main :: IO ()main = do print $ dfs 1 2 myGraph print $ dfs 1 5 myGraphWe 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 runJust 1Nothing