Do you have what it takes to understand Haskell’s monads? I think you do. I recently learned how they work, and unlike most people who add way too much rigor to their sterile explanations, I can explain it. In a previous blog post I said that Haskell monads are a data kind that let us write functions that can work on any data type that contains another type. Since then I’ve learned there’s way more to what they can do, and that that’s not actually what they were meant for.
The point of monads is that they let you chain together operations. Let’s look at an example. Say that you wrote some code in Haskell that’s part of a program which uses a configuration file. The program starts up, reads the config file, calls up the first function and passes a huge number of arguments to the function.
That huge number of arguments would be mostly the configuration variables from the file. We would write many functions for the program that need to access the config data so each of them gets a huge number of arguments, and then when we add a new config variable we need to redo a lot of code for each major function.
Obviously that’s a terrible way to do things. So we might instead create a data structure that contains all the config variables and pass that to every function like so:
data Conf = Conf { maxTemp :: Double,
randSeed :: Int,
maxNumPlayers :: Int,
screenWidth :: Int,
screenHeight :: Int,
aiModelFileNameForAutoModeration :: String} deriving(Show)
fn :: Conf -> Int -> Result
fn cnf@(Conf { randSeed = stupidShitWeNeed }) someKindOfInt = do
let res1 = fn2 cnf 5
res2 = importantCode cnf stupidShitWeNeed
res3 = moreImportantCode cnf someKindOfInt
res1 ++ res2 ++ res3
This looks better than our ugly solution from before, but it can look even nicer. Meet the reader monad. The reader monad lets you write functions which can query stuff from your data structure without having to pass the structure as an argument. Like so:
import Control.Monad.Reader
fn :: Int -> Reader Conf Result
fn someKindOfInt = do
stupidShitWeNeed <- reader randSeed
res1 <- fn2 5
res2 <- importantCode stupidShitWeNeed
res3 <- moreImportantCode someKindOfInt
return (res1 ++ res2 ++ res3)
That sounds nice and all, but how does it work? Well the secret is Haskell’s do notation. When code is in a do block like so:
fn a b = do
let f = fn2 a
f2 <- fn3 f b
return f2
The do block automagically chains the operations together. Monads are a data kind that only need a few methods and one of them is the >>= method. That method takes in something of that particular monad kind, applies a function to it as the second argument which will be applied to what’s in the monad, and then returns the result wrapped in a monad. So, in our example above, f is passed as a function to the >>= method as the second argument, and the first argument is f2 (do notation can lead to rearranging the order of things), and then the result is returned wrapped in a monad since >>= always returns a result in a monad.
But wait! There’s more! Consider the following code:
fn1 :: Int -> Int -> Either String Int
fn1 a b = do
if b == 0
then
Left "You can't divide by zero!"
else
Right (div a b)
fn2 :: Int -> Int -> Int -> Either String (Int, Int, Int)
fn2 x y z = do
let dx = x * x
dy = y * y
dz = z * z
r = sqrt (dx + dy + dz)
retx = fn1 dx r
rety = fn1 dy r
retz = fn1 dz r
....
I couldn’t even bring myself to finish that function because it was quickly becoming so ugly. Here we have code that awkwardly tries to deal with errors. Every major function in this hypothetical program can call up another function and get an Either type that contains either an error message or the return result. As you can imagine, having to constantly check the return result for errors and throw those errors up the stack gets annoying. So let’s do it like this:
import Control.Monad.Except
fn1 :: Int -> Int -> Except String Int
fn1 a b | b == 0 = throwError "You can't divide by zero!"
fn1 a b = return (div a b)
fn2 :: Int -> Int -> Int -> Except String (Int, Int, Int)
fn2 x y z = do
let dx = x * x
dy = y * y
dz = z * z
r = sqrt (dx + dy + dz)
retx <- fn1 x r
rety <- fn1 y r
retz <- fn1 z r
return (retx, rety, retz)
This one was much easier. I was even able to finish it.
Where we use the Except monad we chain together operations and each of them can throw up an error message. When we run our main code with the runExcept function, fn2 will return either the error message or the return result. We can use the runExcept function at the top level of our code (the part that runs the main part of code that has no side effects). This means that we don’t have to pollute our arguments, return types, and code with a bunch of crap for transferring things along our pipeline.
There are many different monads that we can use. Here are some of my favorite:
- The except monad for errors
- The state monad for tracking the state of the program
- The reader monad for storing config information
- The logic monad for backtracking (Yes, like the kind prolog does, albeit a bit more awkardly)
- The par monad for launching computations that can be done in parallel
- The parsec monad from megaparsec for parsing things.
And many more. You can also combine them using monad transformers so you can have state, config files, parallelism, parsing, errors and anything else like this:
import Control.Monad.State
import Control.Monad.Reader
data St = St { players :: [Player], deadNPCs :: [NPC], aliveNPCs :: [NPC], sleepingNPCs :: [NPC], npcsWithSleepApnea :: [NPC]}
data Conf = Conf { maxNumPlayers :: Int, mapName :: String, mapData :: GameMap, minNumRandomBansPerHour :: Int}
type Mon = StateT St (Reader Conf)
runner :: Mon a -> Conf -> St -> a
runner a cnf st = let (ret, _) = runState a in (runReader cnf ret)
I used to be afraid of monads because I didn’t understand them and I thought that when you put data into a monad you can’t get it back out, but really monads are simple. They’re generally containers that hide away ugly repetitive code, and the vast majority of them will let you extract your data from them.