IO: Talking to the Outside World
I hope you have by now come to appreciate that monads are a useful tool for
expressing lots of different types of side effects of a computation: exception
handling, non-determinism, stateful computations, and many more. However, all
the monads we have discussed so far do nothing but make expressing such
computations more convenient. We could have implemented exception handling,
non-determinism, stateful computations, etc. using pure functions. In fact,
that's exactly what we did when we defined the Monad
instances for Maybe
,
lists, and State
. Performing I/O, like reading files or accessing a database
over a network, is a different story. These are true side effects that cannot be
modelled using pure functions.
In its youth, Haskell used a different mechanism to build a bridge between pure functions and the dirty but necessary world that surrounds our program. The fact that we can use monads to model side effects, including I/O, was discovered only later, and then this tool introduced into Haskell to support I/O developed a life of its own and found applications in modelling all kinds of side effects.
So IO
is the "original monad" in Haskell in a sense, the one that started it
all. The reason why we discuss it second to last is that it is useful to
understand the State
monad in order to grasp how the IO
monad ticks.
Let's figure out what we want to capture. A computation of type IO a
should
produce a return value of type a
, but it can read and write files, send
messages across the network or format our hard drive along the way. What we have
learned so far is that monads provide us with a purely functional approach to
model side effects. What is the effect of reading and writing files, sending
messages across the network or moving a robot arm? Each of these operations
changes the world that surrounds our program. The robot arm may have pointed up
before our program ran, and now the arm points down. Or there may have been
files on our hard drive before we chose to format it, and now there are none.
Each of these operations changes the state of the world around our program. This
sounds like something we could model using the State
monad. All we need is a
data type RealWorld
that contains information about the state of every single
atom in the universe. In addition to returning a value of type a
, a
computation of type IO a
then takes the original state of the universe as
input and, along with its return value, returns an updated state of the
universe. We simply have
type IO a = State RealWorld a
Of course, there is no way to implement a type RealWorld
that really stores
the state of the entire universe. What we can do is to define a type
RealWorld
that doesn't really store anything but logically represents the
state of the universe. It's a useful abstraction to model the idea of
threading the state of the whole universe through our program. And that's what
the I/O monad is: a state monad whose state is the whole world.
If you dig deep into the standard library, you will find that IO
isn't defined
via the State
monad but via a more low-level cousin of State
called
State#
. RealWorld
is an actual type in the Haskell standard library—you can
Hoogle it—and the real definition of IO
using the State#
monad does the same
thing in spirit as our made-up definition given above.
Since RealWorld
and State#
are built deeply into Haskell's runtime system,
they don't have implementations in pure Haskell that we could discuss, nor can
we provide implementations of return
and (>>=)
for the IO
monad in pure
Haskell. That's exactly the point: computations in the IO
monad involve real
side effects and thus cannot be expressed using pure functions. However, the
analogy to the State
monad goes a long way to understand how return
and
(>>=)
behave.
Remember, return x
is supposed to take a pure value x
and decorate it using
our monad. In the case of the State
monad, return x
is a stateful function,
only it neither reads nor writes the state, it leaves the state alone. But
that's allowed. Mapping this to IO
, we want return x
to be an action in the
IO
monad that returns x
and leaves the state of the world alone. It reads no
files, launches no nukes, and doesn't call your grandmother. It simply returns
x
without performing any I/O.
A computation x >>= f
in the state monad evaluates x
, including the changes
to the state that this makes. Then we apply f
to the value returned by x
.
What f
does with this value may depend on the state left behind by x
and may
modify this state some more. Thus, it is important that f
runs after x
. In
the IO
monad, if x
writes some file and f
reads the same file, we expect
that f
sees what x
has written because in the state of the world left behind
by x
, the file exists and contains the content written by x
. Thus, the real
effect of using the IO
monad to sequence effectful functions is to make sure
that they happen in the correct order.
Pragmatically, you don't need to understand any of this. You could just as well
approach the IO
monad as this magical black box, where a computation like
f :: Int -> IO ()
f x = do
let y = 2 * x
putStrLn $ "The value of y is " ++ show y
behaves exactly like code in imperative programming languages: the statements are executed in sequence, and each statement sees the side effects of the previous statements.
This is how most introductions to Haskell approach the IO
monad, but in my
opinion, this only helps to create the impression that monads are deeply magical
and hard to understand. In reality, monads are simply a purely functional means
to describe computations with side effects, and viewing the IO
monad as a
state monad with the whole world as its state is exactly the right abstraction
to model computations that create effects that can be seen outside of our
program, on our hard drive, in our bank account or in the silly dance performed
by a robot.
So now that we know what the IO
monad is, what functions are there to
perform I/O? Obviously, the list is looooong because interacting with every
single peripheral device attached to our computer is a form of I/O, be it a
graphics card, a network card, a hard drive, a logic board, a mouse or keyboard,
etc, etc, etc. I will discuss only the I/O functions that you need to get going.
In particular, these functions are enough to complete your Haskell programming
project.