Demystifying Monads with I/O
Perplexed by Monads? Great! I’m going to show you if you’re comfortable with classes and objects, you’re already acquainted with Monads.
Table of Contents
Before we start, what is a Monad? Well according to Wikipedia:
Monads are a way to structure computations as a sequence of steps, where each step not only produces a value but also some extra information about the computation, such as a potential failure, non-determinism, or side effect.
You can think of Monads as a design pattern for simulating side-effects. This is super helpful for a lazy and pure programming language where side-effects are prohibited (purity) and execution order isn’t guaranteed (laziness).
If none of that makes sense, don’t worry. Just keep these 3 things in mind:
- sequence of steps
- produces a value
- extra information about the computation
For illustration I’ll be using Python and Haskell. If you’re unfamiliar with Haskell, checkout my whistle-stop tour to get up to speed.
Python on the other hand is basically runnable pseudocode, but to keep us all on the same page I’ll explain things as we go.
A Python I/O Manager#
In object-orientated programming, we create classes and objects to represent components of the problem we’re solving. Let’s create an I/O manager class in Python:
1import sys
2
3class IOManager:
4
5 def __init__(self):
6 self.stdout = sys.stdout
7 self.stdin = sys.stdin
8 self.stderr = sys.stderr
9
10 def read_line(self) -> str:
11 return self.stdin.readline()
12
13 def write_line(self, msg: str) -> None:
14 self.stdout.write(msg + "\n")
15
16 def write_error(self, msg: str) -> None:
17 self.stderr.write("Error: " + msg + "\n")
To summarize, we:
- Initialise the
IOManager
class with 3 I/O streams:stdin
,stdout
andstderr
. - Define 3 methods:
read_line
: reads a line fromstdin
.write_line
: takes astr
argument and writes to the terminal with a newline at the end.write_error
: same aswrite_line
except it writes tostderr
.
Pretty straightforward right?
Let’s use our IOManager
to write a little program.
- We’ll ask the user their name.
- Check their name is valid
- Print a message saying “Hello <name>!” if it is, and “<name> isn’t a valid name!” if not.
1import sys
2
3class IOManager:
4
5 def __init__(self):
6 self.stdout = sys.stdout
7 self.stdin = sys.stdin
8 self.stderr = sys.stderr
9
10 def read_line(self) -> str:
11 return self.stdin.readline()
12
13 def write_line(self, msg: str) -> None:
14 self.stdout.write(msg + "\n")
15
16 def write_error(self, msg: str) -> None:
17 self.stderr.write("Error: " + msg + "\n")
18
19# create a new IOManager instance
20my_io_manager: IOManager = IOManager()
21
22my_io_manager.write_line("What is your name?")
23
24name_str: String = my_io_manager.read_line()
25
26# check each character is alphabetic
27# anything else isn't valid!
28if not all(c.is_alpha() for c in name_str):
29 my_io_manager.write_error(name_str + " isn't a valid name!")
30else:
31 my_io_manager.write_line("Hello " + name_str + "!")
Running the program:
What is your name?
> Dave
Hello Dave!
And with bad input:
What is your name?
> Dave123
Error: Dave123 isn't a valid name!
🥱 Kind of boring… But if you can follow this, you’re halfway there with Monads!
Now that we’ve implemented our IOManager
, let’s implement the same program in Haskell with Monads.
I…Oh?#
To get started, let’s think about printing to the screen.
putStrLn
is a Haskell function which takes a String
argument and prints it to the screen. It has the following type signature:
putStrLn :: String -> IO ()
Hmm.. What on Earth is this IO ()
? Well, let’s look again at the write_line
function from our IOManager
class:
def write_line(self, msg: str) -> None:
self.stdout.write(msg + "\n")
Interesting. Our write_line
method takes a str
and “returns” None
, meaning it doesn’t return anything. putStrLn
also takes a String
, but it returns this strange IO ()
thing. What gives?
Well think of it this way: our IOManager
object handles reading and writing to the terminal. Haskell does the same through the IO
Monad. The IO
Monad is just like IOManager
: it interacts with the outside world for us, and returns nice, pure data back.
When we call write_line
or read_line
from the IOManager
, we don’t care how it works, we just want the “effect” to happen (printing to the terminal or getting some input from the user). The same is true for Monads - we don’t care how the Monad does it’s thing, we just want the thing to happen.
With that in mind, IO ()
means “do some I/O and give us ()
back”1, or in other words “do some I/O and return nothing”. Kind of makes sense right? When we print something to the screen, we only want to print to the screen - we don’t want anything back.
Let’s look at the type of the Haskell getLine
function, which reads a line from the terminal, and compare it with read_line
from the IOManager
:
getLine :: IO String
def read_line(self) -> str:
return self.stdin.readline()
The IOManager
read_line
function takes no arguments and returns a str
. The Haskell getLine
function takes no arguments and returns IO String
.
Interesting… These type signatures are almost the same, except getLine
wraps it’s result (the String
) within IO
. That’s down to more Monad awesomeness: Monads provide a context, meaning you can represent different situations as Monadic data types.
For example, computations which may fail can be represented with a Monad called Maybe
. The Maybe
Monad wraps successful results in Just
, and returns a Nothing
when a failure occurs.
maybeDivide :: Int -> Int -> Maybe Int
maybeDivide a b = if b == 0 then Nothing
else Just (a `div` b)
- return
Nothing
if you try to divide something by 0. - otherwise, return
Just
the result of dividinga
byb
.
Notice the result is wrapped in the Maybe
context (the Maybe Int
).
Going back to the str
returned by read_line
, we don’t have to wrap it in anything, because read_line
is defined within the context of IOManager
- it (and the other methods) are defined inside the IOManager
class, so that is their context.
With Monads the context is explicit (the Maybe in ‘Maybe Int
’ or the IO in ‘IO String
’) whereas with our IOManager
it’s implicit - it’s still there but it’s hidden from us.
Ok we’ve got a mental model for Monads now: it’s like an object which handles some impure action for us and wraps results in a context. Great - we’ve got a way of doing an impure action in a pure language.
But most imperative programs aren’t a single step, they’re a sequence. They “do A then do B then do C” and so on. Also they likely keep data from previous steps to use in later steps. Our program for checking a user’s name certainly does:
1import sys
2
3class IOManager:
4
5 def __init__(self):
6 self.stdout = sys.stdout
7 self.stdin = sys.stdin
8 self.stderr = sys.stderr
9
10 def read_line(self) -> str:
11 return self.stdin.readline()
12
13 def write_line(self, msg: str) -> None:
14 self.stdout.write(msg + "\n")
15
16 def write_error(self, msg: str) -> None:
17 self.stderr.write("Error: " + msg + "\n")
18
19# create a new IOManager instance
20my_io_manager: IOManager = IOManager()
21
22my_io_manager.write_line("What is your name?")
23
24name_str: str = my_io_manager.read_line()
25
26# check each character is alphabetic
27# anything else isn't valid!
28if not all(c.is_alpha() for c in name_str):
29 my_io_manager.write_error(name_str + " isn't a valid name!")
30else:
31 my_io_manager.write_line("Hello " + name_str + "!")
See how name_str
is used after we get it from write_line
on line 24
.
How can we sequence our impure steps in Haskell just like we sequence them in an imperative language?
High-Ho, High-Ho, Into IO
We Go!#
To build up to sequencing, let’s implement our “check user’s name” program in Haskell.
Starting at the top, we’ll ask the user their name:
whatIsYourName :: IO ()
whatIsYourName = putStrLn "What is your name?"
putStrLn
prints a string to the terminal and has typeString -> IO ()
.
Ok now we’ve asked for their name, let’s actually get it from the terminal:
getName :: IO String
getName = getLine
getLine
reads a line from the terminal and has typeIO String
.
Cool, now lets implement a function which checks that the user’s name is valid (only contains alphabetic characters), and prints “Hello <name>!” if it is, and “<name> isn’t a valid name!” if it isn’t.
To check a character is alphabetic, we’ll use the function isAlpha
from the Data.Char
module. isAlpha
has type Char -> Bool
.
This will work for checking a single character, but we want to check all the characters in the person’s name. We can use another function called all
which has type Foldable t => (a -> Bool) -> t a -> Bool
.
We won’t go into too much detail, as all we need to know is that all
takes a predicate (the (a -> Bool)
), some kind of structure to check against (the t a
), and returns True
if each element in the structure returns True
for the predicate. For us, the predicate is isAlpha
and our structure is our String
.
Cool, let’s implement that.
We’ll call our function printResult
, and it will take a single String
argument which is the input we’ve just read from the temrinal:
import Data.Char(isAlpha)
printResult :: String -> IO ()
printResult name = if nameIsValid then putStrLn ("Hello " ++ name ++ "!")
else putStrLn (name ++ " isn't a valid name!")
where nameIsValid = all isAlpha name
Nice, we’ve got all the logic down:
- We ask user their name with
whatIsYourName
. - We get their name from the terminal with
getName
. - We check their name is valid, and print the result using
printResult
.
Now we need to tie it all together.
Right off the bat there’s an issue - we can’t really represent sequencing easily since our functions are kind of a one-shot-deal. Functions in Haskell can be defined recursively, so maybe we can define our sequencing recursively.
Let’s define a function called step
, which will take an Int
argument and will do a specific part of our program depending on the argument. For example, step 1
shoud run whatIsYourName
since that’s the first step of our program. step 2
should ask the user their name, then we’ll do our check and printResult
.
step :: Int -> IO ()
-- ask their name then go to step 2, wait... what is '>>'?
step 1 = whatIsYourName >> step 2
-- show the person's name, then finish
step 2 = getName >>= printResult
-- there nothing after step 2!
step _ = return ()
Here is the complete code somewhat tidied up:
1module main where
2
3import Data.Char(isAlpha)
4
5-- ask their name
6whatIsYourName :: IO ()
7whatIsYourName = putStrLn "What is your name?"
8
9getName :: IO String
10getName = getLine
11
12-- check the person's name is valid
13printResult :: String -> IO ()
14printResult name = if nameIsValid then putStrLn ("Hello " ++ name ++ "!")
15 else putStrLn (name ++ " isn't a valid name!")
16 where nameIsValid = all isAlpha name
17
18step :: Int -> IO ()
19-- ask their name then go to step 2
20step 1 = whatIsYourName >> step 2
21-- show the person's name, then finish
22step 2 = getName >>= printResult
23-- there nothing after step 2!
24step _ = return ()
25
26-- start at step 1
27main :: IO ()
28main = step 1
YUCK! That’s about as readable as your doctor’s handwriting! Surely there’s a better way (spoiler: there is).
Woah-oh! Sweet Child O’>>=#
Let’s zoom in on this funny looking >>=
symbol. Can you spot it above? This is bind
.
bind
sequences “impure” (Monadic) actions together. It can take something like IO String
, unwrap the IO
, then pass the String
to the function on it’s right (“bind” it). This is what getLine >>= printResult
does: it takes the IO String
returned from getLine
, removes the IO
wrapper, and passes the String
to printResult
which, as you can see, expects a String
argument (called name
) and does some more IO ()
2.
As cool as >>=
is, using it can be unwieldy, so the Haskell team invented ‘do
’ notation. If we use the do
notation, and leverage the power of bind
more effectively, we can rewrite our function in a more sane way:
1module main where
2
3import Data.Char(isAlpha)
4
5main = do putStrLn "What is your name?"
6 name <- getLine -- read line from terminal
7 if nameIsValid then
8 putStrLn ("Hello " ++ name ++ "!")
9 else putStrLn ("Exception " ++ name ++ " is not a valid name!")
10 where nameIsValid = all isAlpha name
Ahh! Much better! We’ve substituted dear old >>=
for do
and <-
. <-
serves the same function as >>=
: take an IO Something
on the right, unwrap the IO
, and assign the Something
to the variable (name
in our case) on the left. And with that, we’re done!
Conclusion#
- Side effects are all about changing state in a specific order.
- We don’t launch the missiles before checking the user has clicked ‘Launch’.
- A Monad is a data type which represents an impure action, such as I/O.
- Monad’s provide a “computational context” - think of the similar purpose of the
IOManager
and theIO
Monad. - Monad’s can wrap “pure” values such as
String
,Int
, or()
. - These results are the result of doing the action.
- We define order using
>>=
(bind) if we want to keep stuff from previous steps. - We can use the
do
notation when sequencing Monads together, to make life easier.
We’ll leave it there for now but there is so much more to this story! We’ll talk more about Monads and other brain-twisting concepts, but on our journeys of discovery we’ll see that, with a little thought and intuition comes immense power!
Bye for now!
-
We call
()
Unit and it’s equivalent toNone
in Python orvoid
in C-like languages: it’s the type of data with no values. ↩︎ -
Also in our recursive example above is
>>
, called ‘then
’. It’s like ‘>>=
’ except it doesn’t unwrap or bind anything - think of it as “do the thing on the left, then the right”. ↩︎