puts("Hello, World!")
A Problem With I/O
"Functional languages have a … problem with I/O", notes Rob Pike.
Actually, almost all languages have a problem with I/O. The vast majority, including many that claim to be functional, simply give up and assume side effects can happen at any time. In those that are decent enough to provide static typing, a "function" that "returns an int" might return an int, or it might launch some missiles and return an int.
Thus Rob Pike’s statement is misleading, as it suggests ignoring a problem is equivalent to solving it.
A few languages confine themselves to pure functions. In Coq one never has to worry about side effects, though I/O in Coq is peculiar: instead of printing "Hello, World!" directly, we output a program, which if interpreted correctly, prints "Hello, World!".
Haskell compromises brilliantly, by fencing off pure and impure functions from one another in a manner akin to the const keyword of C/C++, and tricks us into believing impure functions are actually pure by pretending the real world is a value that can be threaded through them.
The effectiveness of this solution has drawbacks. The illusion is so good that programmers are fooled into thinking I/O is pure in Haskell. And now that we can write mostly pure code with occasional impure wrappers, researchers have mostly stopped seeking superior alternatives. May we one day escape this uncanny valley.
Hello, World!
Consider a function call that prints a string:
We know it doesn’t just take a string and return nothing. There are side effects, that is, something noticeable happens in the real world.
This suggests introducing a type that represents the real world. The puts
function should take an input world and a string, and output a whole new world:
a world where the given string has been printed. Similarly, a gets
function
that reads a string should take an input world, then output a string and a new
world: a world where a string has been consumed from standard input.
In Go, it might go something like this:
func puts(s string, w world) world func gets(w world) (string, world) func main(w world) world { w1 := puts("Enter a string:", w) s, w2 := gets(w1) return puts("You typed: " + s, w2) }
We’ve purified our code. Everything is immutable. Variable assignments only occur during initialization. I/O is no problem.
How does the compiler deal with the world
type? Easy! The compiler initially
treats it like any other type, then after applying optimizations that work only
for pure functions, it discards objects of type world
.
However, the syntax is clumsy. Must we clutter our source with world
parameters?
How to Change the World
Before long, we notice any sensible function that outputs a world must also expect an input world, and vice versa. Moreover, there must be exactly one input world and one output world, the former representing the old world and the latter the new. To describe side effects is to describe the world before and after they transpire. [The real world is a global object, both literally and in computer science parlance.]
This inspires us to define a family of types to represent world-changing
functions, and to redefine puts
and gets
as follows:
type IO func(w world) ( world) type IOString func(w world) (string, world) type IOInt func(w world) ( int, world) ... func puts(s string) IO func gets() IOString
We’ve added a layer of indirection. Before, the puts
function took a world and
a string, then returned a changed world. Now, it takes a string and returns a
world-changing function, which must eventually be run.
It’s like Unix pipes, but instead of transforming text, we chain together functions that transform the world. More accurately, it’s like MS-DOS fake pipes, because each stage must finish completely before the next can begin.
func main(w world) world { s, w1 := gets() (puts("Enter a string:") (w)) return puts("You typed: " + s) (w1) }
The End of the World
We’ve reduced some clutter, but the Go code still looks odd. Worse yet, nothing prevents us writing bad code that reuses a stale version of the world: a rift in the space-time continuum!
It’s better in Haskell. Firstly, thanks to parameterized types, we can just
define IO
(which is actually predefined by the Haskell Prelude), and all else
will follow:
puts :: String -> IO () gets :: IO String
Secondly, Haskell makes us pass the world parameter implicitly:
main :: IO () main = puts "Enter a string:" >> gets >>= (\s -> puts "You typed: " ++ s)
The tacit world parameter is more than a mere convenience. It ensures we can never do silly things like destroy the world or return the wrong world. The world is automatically threaded correctly through our code. Again, we’re reminded of Unix pipelines, which send the output of one program to the input of the next even though standard input and standard output are implied without being stated. Hiding them prevents us from messing them up.
The presence of the IO
type constructor means a function is impure, just as
the absence of the const
keyword in C/C++ means data might be modified.
Using Haskell’s do
notation, we can mimic conventional languages:
main = do puts "Enter a string:" s <- gets puts ("You typed: " ++ s)
Loosely speaking, within a do
block, the output of one line becomes the input
of the next.
The above isn’t merely theoretical, by the way. We can turn our code into a working program by adding:
puts = putStrLn gets = getLine
Haskell syntax is clean, yet powerful. We have mixed pure and impure code with ease, yet we can distinguish between the two at a glance.
Purity Test
One might argue IO functions are pure; it’s just that we inconveniently lack the power to create and destroy worlds so we cannot do things we often do with pure functions, such as run them multiple times on the same input.
But time passes while the computer is waiting for user input,
that is, the real world changes even after it is passed to getLine
, so
there is something imprecise about viewing IO
functions as pure.
Perhaps we can tolerate these discrepancies in a single-threaded program.
However, functions such as forkIO
also live in IO
. What does it mean to
give 2 threads 2 copies of the world? Didn’t we say the world is unique?
And even if we could copy the world, what happens when the threads join? How
can two different worlds merge?
An alternative solution
The Clean programming language solves the I/O problem with a uniqueness type system.
Monads
We achieved simplicity making the world
arguments implicit, and using do
notation to replace nested function calls with a sequence of function
calls.
We can generalize this technique to eliminate code duplication in other contexts. When we do, we call it a monad.
If you’ve been studying denotational semantics. then sooner or later this trick will occur to you, and from this point of view, "monads are just as interesting and important as parentheses"
Otherwise, to paraphrase a famous movie quote, some believe that unfortunately, no one can be told what a monad is; you have to write one for yourself. To this end, we replace our CodeJam module with a monad. Our goal is to be able to hide details so we can write something like:
-- In this hypothetical Google Code Jam problem, each case consists of -- two integers separated by a space, and we're to output their product. import Jam main = jam $ do [m, n] <- getints return $ show $ m * n
While debugging, I found it useful to be able to extract a particular set of cases from an input file, so I added a second list of strings to the state: this second list holds the input read so far for a single test case. Suppose we want to generate a new input file that consists of the first, third and sixth cases of another file. Then we want to be able to temporarily modify our solution to print test inputs instead:
import Jam main = jamCases [1,3,6] $ do [m, n] <- getints return $ show $ m * n
We imagine there are other applications: perhaps we want to print input-output pairs for each case.
The idea is to surreptitiously attach two lists of strings to everything we do. One list holds the input that is about to be read, and the other holds the input we have already read. Our code is just like the IO monad except instead of tacitly modifying a world, we’re tacitly modifying two lists of strings.
module Jam ( jam, jamOff, jamCases, gets, getsn, getints, getintsn, getintegers, getdbls ) where import Control.Monad import Data.List data Jam a = Jam (([String], [String]) -> (a, ([String], [String]))) instance Functor Jam where fmap = liftM instance Applicative Jam where pure k = Jam (\s -> (k, s)) (<*>) = ap instance Monad Jam where Jam c1 >>= fc2 = Jam (\s0 -> let (r, s1) = c1 s0 Jam c2 = fc2 r in c2 s1) return = pure gets :: Jam String gets = Jam (\(x:xs, ys) -> (x, (xs, ys++[x]))) getsn :: Int -> Jam [String] getsn n = replicateM n gets getints :: Jam [Int] getints = getem getintegers :: Jam [Integer] getintegers = getem getintsn :: Int -> Jam [[Int]] getintsn = getemn getdbls :: Jam [Double] getdbls = getem jamOff offset f = interact $ \s -> let Jam fun = f (_n:inp) = lines s n = read _n in unlines $ zipWith (++) (map (\k -> "Case #" ++ show (k+offset) ++ ": ") [1..n]) $ unfoldr (\(xs, _) -> Just $ fun (xs, [])) (inp, []) jam f = interact $ \s -> let Jam fun = f (_n:inp) = lines s n = read _n in unlines $ zipWith (++) (map (\k -> "Case #" ++ show k ++ ": ") [1..n]) $ unfoldr (\(xs, _) -> Just $ fun (xs, [])) (inp, []) jamCases idxs f = interact $ \s -> let Jam fun = f (_n:inp) = lines s n = read _n cases = take n $ unfoldr (\xy -> let (_, (xs, ys)) = fun xy in Just (ys, (xs, []))) (inp, []) in unlines $ (:) (show $ length idxs) $ concatMap ((cases!!) . (+ (-1))) idxs
It turns out we often want to attach some kind of state to a sequence of functions. This is exactly why the State monad was written, and in fact, we could have just defined:
data Jam a = State ([String], [String])
But we should write out a monad in full (even if it is copied from elsewhere!) at least once while learning Haskell.
Our code resembles what we might write if we had stuck with the IO monad.
iogetints :: IO [Int] iogetints = do s <- getLine return $ map read $ words s main = do [m, n] <- iogetints putStrLn $ show $ m * n
However, it’s good form to steer clear of IO as much as possible. Especially if it’s as easy as swapping out the IO monad with a State monad. Besides feeling good about ourselves, we can, for example, trivially feed a string to our program instead of reading from standard input. We lose this flexibility if we use the IO monad everywhere.