| Prev: More examples with monad transformers | TOC: Contents | Next: Continuing Exploration |
As the number of monads combined together increases, it becomes increasingly important to manage the stack of monad transformers well.
Once you have decided on the monad features you need, you must choose
the correct order in which to apply the monad transformers to achieve
the results you want. For instance you may know that you want a
combined monad that is an instance of MonadError and
MonadState, but should you apply StateT to
the Error monad or ErrorT to the State
monad?
The decision depends on the exact semantics you want for your
combined monad. Applying StateT to the Error
monad gives a state transformer function of type
s -> Error e (a,s). Applying
ErrorT to the State monad gives a state transformer
function of type s -> (Error e a,s).
Which order to choose depends on the role of errors in your computation.
If an error means no state could be produced, you would apply StateT
to Error. If an error means no value could be produced, but the
state remains valid, then you would apply ErrorT to State.
Choosing the correct order requires understanding the transformation carried out by each monad transformer, and how that transformation affects the semantics of the combined monad.
The following example demonstrates the use of multiple monad transformers.
The code uses the StateT monad transformer along with the List monad to
produce a combined monad for doing stateful nondeterministic computations.
In this case, however, we have added the WriterT monad transformer
to perform logging during the computation. The problem we will apply this monad
to is the famous N-queens problem: to place N queens on a chess board so that
no queen can attack another.
The first decision is in what order to apply the monad transformers.
StateT s (WriterT w []) yields a type
like: s -> [((a,s),w)].
WriterT w (StateT s []) yields a type
like: s -> [((a,w),s)]. In this case, there is
little difference between the two orders, so we will choose the second
arbitrarily.
Our combined monad is an instance of both MonadState and
MonadWriter, so we can freely mix use of get,
put, and tell in our monadic computations.
| Code available in example25.hs |
|---|
-- this is the type of our problem description
data NQueensProblem = NQP {board::Board,
ranks::[Rank], files::[File],
asc::[Diagonal], desc::[Diagonal]}
-- initial state = empty board, all ranks, files, and diagonals free
initialState = let fileA = map (\r->Pos A r) [1..8]
rank8 = map (\f->Pos f 8) [A .. H]
rank1 = map (\f->Pos f 1) [A .. H]
asc = map Ascending (nub (fileA ++ rank1))
desc = map Descending (nub (fileA ++ rank8))
in NQP (Board []) [1..8] [A .. H] asc desc
-- this is our combined monad type for this problem
type NDS a = WriterT [String] (StateT NQueensProblem []) a
-- Get the first solution to the problem, by evaluating the solver computation with
-- an initial problem state and then returning the first solution in the result list,
-- or Nothing if there was no solution.
getSolution :: NDS a -> NQueensProblem -> Maybe (a,[String])
getSolution c i = listToMaybe (evalStateT (runWriterT c) i)
-- add a Queen to the board in a specific position
addQueen :: Position -> NDS ()
addQueen p = do (Board b) <- gets board
rs <- gets ranks
fs <- gets files
as <- gets asc
ds <- gets desc
let b' = (Piece Black Queen, p):b
rs' = delete (rank p) rs
fs' = delete (file p) fs
(a,d) = getDiags p
as' = delete a as
ds' = delete d ds
tell ["Added Queen at " ++ (show p)]
put (NQP (Board b') rs' fs' as' ds')
-- test if a position is in the set of allowed diagonals
inDiags :: Position -> NDS Bool
inDiags p = do let (a,d) = getDiags p
as <- gets asc
ds <- gets desc
return $ (elem a as) && (elem d ds)
-- add a Queen to the board in all allowed positions
addQueens :: NDS ()
addQueens = do rs <- gets ranks
fs <- gets files
allowed <- filterM inDiags [Pos f r | f <- fs, r <- rs]
tell [show (length allowed) ++ " possible choices"]
msum (map addQueen allowed)
-- Start with an empty chess board and add the requested number of queens,
-- then get the board and print the solution along with the log
main :: IO ()
main = do args <- getArgs
let n = read (args!!0)
cmds = replicate n addQueens
sol = (`getSolution` initialState) $ do sequence_ cmds
gets board
case sol of
Just (b,l) -> do putStr $ show b -- show the solution
putStr $ unlines l -- show the log
Nothing -> putStrLn "No solution"
|
The program operates in a similar manner to the previous example, which solved
the kalotan puzzle. In this example, however, we do not test for consistency
using the guard function. Instead, we only create branches
that correspond to allowed queen positions. We use the added logging facility
to log the number of possible choices at each step and the position in which
the queen was placed.
There is one subtle problem remaining with our use of multiple monad transformers. Did you notice that all of the computations in the previous example are done in the combined monad, even if they only used features of one monad? The code for these functions in tied unneccessarily to the definition of the combined monad, which decreases their reusability.
This is where the lift function from the MonadTrans class
comes into its own. The lift function gives us the ability to write
our code in a clear, modular, reusable manner and then lift the computations
into the combined monad as needed.
Instead of writing brittle code like:
logString :: String -> StateT MyState (WriterT [String] []) Int logString s = ... |
logString :: (MonadWriter [String] m) => String -> m Int logString s = ... |
logString computation into the combined monad
when we use it.
You may need to use the compiler flags -fglasgow-exts with GHC or
the equivalent flags with your Haskell compiler to use this technique. The issue
is that m in the constraint above is a type constructor, not a type,
and this is not supported in standard Haskell 98.
When using lifting with complex transformer stacks, you may find yourself
composing multiple lifts, like
lift . lift . lift $ f x.
This can become hard to follow, and if the transformer stack changes
(perhaps you add ErrorT into the mix) the lifting may need
to be changed all over the code. A good practice to prevent this is
to declare helper functions with informative names to do the lifting:
liftListToState = lift . lift . lift |
The hardest part about lifting is understanding the semantics of lifting computations, since this depends on the details of the inner monad and the transformers in the stack. As a final task, try to understand the different roles that lifting plays in the following example code. Can you predict what the output of the program will be?
| Code available in example26.hs |
|---|
-- this is our combined monad type for this problem
type NDS a = StateT Int (WriterT [String] []) a
{- Here is a computation on lists -}
-- return the digits of a number as a list
getDigits :: Int -> [Int]
getDigits n = let s = (show n)
in map digitToInt s
{- Here are some computations in MonadWriter -}
-- write a value to a log and return that value
logVal :: (MonadWriter [String] m) => Int -> m Int
logVal n = do tell ["logVal: " ++ (show n)]
return n
-- do a logging computation and return the length of the log it wrote
getLogLength :: (MonadWriter [[a]] m) => m b -> m Int
getLogLength c = do (_,l) <- listen $ c
return (length (concat l))
-- log a string value and return 0
logString :: (MonadWriter [String] m) => String -> m Int
logString s = do tell ["logString: " ++ s]
return 0
{- Here is a computation that requires a WriterT [String] [] -}
-- "Fork" the computation and log each list item in a different branch.
logEach :: (Show a) => [a] -> WriterT [String] [] a
logEach xs = do x <- lift xs
tell ["logEach: " ++ (show x)]
return x
{- Here is a computation in MonadState -}
-- increment the state by a specified value
addVal :: (MonadState Int m) => Int -> m ()
addVal n = do x <- get
put (x+n)
{- Here are some computations in the combined monad -}
-- set the state to a given value, and log that value
setVal :: Int -> NDS ()
setVal n = do x <- lift $ logVal n
put x
-- "Fork" the computation, adding a different digit to the state in each branch.
-- Because setVal is used, the new values are logged as well.
addDigits :: Int -> NDS ()
addDigits n = do x <- get
y <- lift . lift $ getDigits n
setVal (x+y)
{- an equivalent construction is:
addDigits :: Int -> NDS ()
addDigits n = do x <- get
msum (map (\i->setVal (x+i)) (getDigits n))
-}
{- This is an example of a helper function that can be used to put all of the lifting logic
in one location and provide more informative names. This has the advantage that if the
transformer stack changes in the future (say, to add ErrorT) the changes to the existing
lifting logic are confined to a small number of functions.
-}
liftListToNDS :: [a] -> NDS a
liftListToNDS = lift . lift
-- perform a series of computations in the combined monad, lifting computations from other
-- monads as necessary.
main :: IO ()
main = do mapM_ print $ runWriterT $ (`evalStateT` 0) $ do x <- lift $ getLogLength $ logString "hello"
addDigits x
x <- lift $ logEach [1,3,5]
lift $ logVal x
liftListToNDS $ getDigits 287
|
Once you fully understand how the various lifts in the example work and how lifting promotes code reuse, you are ready for real-world monadic programming. All that is left to do is to hone your skills writing real software. Happy hacking!
| Prev: More examples with monad transformers | TOC: Contents | Next: Continuing Exploration |