Making Sense of Monads!
We have a special announcement this week! We have a new course available at Monday Morning Haskell Academy! The course is called Making Sense of Monads, and as you might expect, it tackles the concept of monads! It's a short, one module course, but it goes into a good amount of detail about this vital topic, and includes a couple challenge projects at the end. Sign up here!. If you subscribe to our mailing list, you can get a special discount on this and our other courses!
In addition to this, we've also got some new blog content! Once again, there's a video, but you can also follow along by scrolling down!
Last week we discussed the function application operator, which I used for a long time as a syntactic crutch without really understanding it. This week we'll take another look at a function-related concept, but we'll relate it to our new monads course. We're going to explore the "function monad". That is, a single-argument function can act as a monad and call other functions which take the same input in a monadic fashion. Let's see how this works!
The Structure of Do Syntax
Let's start by considering a function in a more familiar monad like IO. Here's a function that queries the user for their name and writes it to a file.
ioFunc :: IO ()
ioFunc = do
putStrLn "Please enter your name"
name <- getLine
handle <- openFile "name.txt" WriteMode
hPutStrLn handle name
hClose handle
Do syntax has a discernable structure. We can see this when we add all the type signatures in:
ioFunc :: IO String
ioFunc = do
putStrLn "Please enter your name" :: IO ()
(name :: String) <- getLine :: IO String
(handle :: Handle) <- openFile "name.txt" WriteMode :: IO Handle
hPutStrLn handle name :: IO ()
hClose handle :: IO ()
return name :: IO String
Certain lines have no result (returning ()
), so they are just IO ()
expressions. Other lines "get" values using <-
. For these lines, the right side is an expression IO a
and the left side is the unwrapped result, of type a
. And then the final line is monadic and must match the type of the complete expression (IO String
in this case) without unwrapping it's result.
Here's how we might expression that pattern in more general terms, with a generic monad m
:
combine :: a -> b -> Result
monadFunc :: m Result
monadFunc = do
(result1 :: a) <- exp1 :: m a
(result2 :: b) <- exp2 :: m b
exp3 :: m ()
return (combine result1 result2) :: m Result
Using a Function
It turns out there is also a monad instance for (->) r
, which is to say, a function taking some type r
. To make this more concrete, let's suppose the r
type is Int
. Let's rewrite that generic expression, but instead of expressions like m Result
, we'll instead have Int -> Result
.
monadFunc :: Int -> Result
monadFunc = do
(result1 :: a) <- exp1 :: Int -> a
(result2 :: b) <- exp2 :: Int -> b
exp3 :: Int -> ()
return (combine result1 result2) :: Int -> Result
So on the right, we see an expression "in the monad", like Int -> a
. Then on the left is the "unwrapped" expression, of type a
! Let's make this even more concrete! We'll remove exp3
since the function monad can't have any side effects, so a function returning ()
can't do anything the way IO ()
can.
monadFunc :: Int -> Int
monadFunc = do
result1 <- (+) 5
result2 <- (+) 11
return (result1 * result2)
And we can run this function like we could run any other Int -> Int
function! We don't need a run
function like some other functions (Reader, State, etc.).
>> monadFunc 5
160
>> monadFunc 10
315
Each line of the function uses the same input argument for its own input!
Now what does return
mean in this monadic context? Well the final expression we have there is a constant expression. It must be a function to fit within the monad, but it doesn't care about the second input to the function. Well this is the exact definition of the const
expression!
const :: a -> b -> a
const a _ = a -- Ignore second input!
So we could replace return
with const
and it would still work!
monadFunc :: Int -> Int
monadFunc = do
result1 <- (+) 5
result2 <- (+) 11
const (result1 * result2)
Now we could also use the implicit input for the last line! Here's an example where we don't use return:
monadFunc :: Int -> Int
monadFunc = do
result1 <- (+) 5
result2 <- (+) 11
(+) (result1 * result2)
...
>> monadFunc 5
165
>> monadFunc 10
325
And of course, we could define multiple functions in this monad and call them from one another:
monadFunc2 :: Int -> String
monadFunc2 = do
result <- monadFunc
showInput <- show
const (show result ++ " " ++ showInput)
Like a Reader?
So let's think about this monad more abstractly. This monadic unit gives us access to a single read-only input for each computation. Does this sound familiar to you? This is actually exactly like the Reader
monad! And, in fact, there's an instance of the MonadReader
typeclass for the function monad!
instance MonadReader r ((->) r) where
...
So without changing anything, we can actually call Reader
functions like local
! Let's rewrite our function from above, except double the input for the call to monadFunc
:
monadFunc2 :: Int -> String
monadFunc2 = do
result <- local (*2) monadFunc
showInput <- show
const (show result ++ " " ++ showInput)
...
>> func2 5
"325 5"
>> func2 10
"795 10"
This isomorphism is one reason why you might not use the function monad explicitly so much. The Reader monad is a bit more canonical and natural. But, it's still useful to have this connection in mind, because it might be useful if you have a lot of different functions that take the same input!
If you're not super familiar with monads yet, hopefully this piqued your interest! To learn more, you can sign up for Making Sense of Monads! And if you subscribe to Monday Morning Haskell you can get a special discount, so don't wait!