This tutorial explains how to perform IO in
Haskell, without attempting to give any understanding of monads. We start with the simplest example of IO, then build up to more complex examples. You can either read the tutorial to the end, or stop at the end of any section - each additional section will let you tackle new problems. We assume basic familiarity with Haskell, such as the material covered in chapters 1 to 6 of
Programming in Haskell by
Graham Hutton.
IO FunctionsIn this tutorial I use four standard IO functions:
- readFile :: FilePath -> IO String -- read in a file
- writeFile :: FilePath -> String -> IO () -- write out a file
- getArgs :: IO [String] -- get the command line arguments, from the module System.Environment
- putStrLn :: String -> IO () -- write out a string, followed by a new line, to the console
Simple IOThe simplest useful form of IO is to read a file, do something, then write out a file.
main :: IO ()
main = do
src <- readFile "file.in"
writeFile "file.out" (operate src)
operate :: String -> String
operate = ... -- your code here
This program gets the contents of
file.in, runs the operate function on it, then writes the result to
file.out. The main function contains all the IO operations, while operate is entirely pure. When writing operate you do not need to understand any details of IO. This pattern of IO was sufficient for my first two years of programming Haskell.
Action ListIf the pattern described in Simple IO is insufficient, the next step is a list of actions. A main function can be written as:
main :: IO ()
main = do
x1 <- expr1
x2 <- expr2
...
xN <- exprN
return ()
The main function starts with
do, then has a sequence of
xI <- exprI statements, and ends with
return (). Each statement has a pattern on the left of the arrow (often just a variable), and an expression on the right. If the expression is not of type IO, then you must write
xI <- return (exprI). The return function takes a value, and wraps it in the IO type.
As a simple example we can write a program that gets the command line arguments, reads the file given by the first argument, operates on it, then writes out to the file given by the second argument:
main :: IO ()
main = do
[arg1,arg2] <- getArgs
src <- readFile arg1
res <- return (operate src)
_ <- writeFile arg2 res
return ()
As before, operate is a pure function. The first line after the do uses a pattern match to extract the command line arguments. The second line reads the file specified by the first argument. The third line uses return to wrap a pure value. The fourth line provides no useful result, so we ignore it by writing
_ <-.
Simplifying IOThe action list pattern is very rigid, and people usually simplify the code using the following three rules:
- _ <- x can be rewritten as x.
- If the penultimate line doesn't have a binding arrow (<-) and is of type IO (), then the return () can be removed.
- x <- return y can be rewritten as let x = y (provided you don't reuse variable names).
With these rules we can rewrite our example as:
main :: IO ()
main = do
[arg1,arg2] <- getArgs
src <- readFile arg1
let res = operate src
writeFile arg2 res
Nested IOSo far only the main function has been of type IO, but we can create other IO functions, to wrap up common patterns. For example, we can write a utility function to print nice looking titles:
title :: String -> IO ()
title str = do
putStrLn str
putStrLn (replicate (length str) '-')
putStrLn ""
We can use this title function multiple times within main:
main :: IO ()
main = do
title "Hello"
title "Goodbye"
Returning IO ValuesThe functions we've written so far have all been of type IO (), which lets us perform IO actions, but not give back interesting results. To give back the value x, we write
return x as the final line of the do block. Unlike the imperative language return statement, this return must be on the final line.
readArgs :: IO (String,String)
readArgs = do
xs <- getArgs
let x1 = if length xs > 0 then xs !! 0 else "file.in"
let x2 = if length xs > 1 then xs !! 1 else "file.out"
return (x1,x2)
This function returns the first two command line arguments, or supplies default values if fewer arguments are given. We can now use this in the main program from before:
main :: IO ()
main = do
(arg1,arg2) <- readArgs
src <- readFile arg1
let res = operate src
writeFile arg2 res
Now, if less than two arguments are given, the program will use default file names instead of crashing.
Optional IOSo far we've only seen a static list of IO statements, executed in order. Using if, we can choose what IO to perform. For example, if the user enters no arguments we can tell them:
main :: IO ()
main = do
xs <- getArgs
if null xs then do
putStrLn "You entered no arguments"
else do
putStrLn ("You entered " ++ show xs)
For optional IO you make the final statement of the do block an if, then under each branch continue the do. The only subtle point is that the else must be indented by one more space than the if. This caveat is widely considered to be a bug in the definition of Haskell, but for the moment, the extra space before the else is required.
Break TimeIf you've gone from understanding no IO to this point in the tutorial, I suggest you take a break (a cookie is recommended). The IO presented above is all that imperative languages provide, and is a useful starting point. Just as functional programming provides much more powerful ways of working with functions by treating them as values, it also allows IO to be treated as values, which we explore in the rest of the tutorial.
Working with IO ValuesThe next stage is to work with IO as values. Until now, all IO statements have been executed immediately, but we can also create variables of type IO. Using our title function from above we can write:
main :: IO ()
main = do
let x = title "Welcome"
x
x
x
Instead of running the IO with
x <-, we have placed the IO value in the variable x, without running it. The type of x is IO (), so we can now write
x on a line to execute the action. By writing the x three times we perform the action three times.
Passing IO ArgumentsWe can also pass IO values as arguments to functions. In the previous example we ran the IO action three times, but how would we run it fifty times? We can write a function that takes an IO action, and a number, and runs the action that number of times:
replicateM_ :: Int -> IO () -> IO ()
replicateM_ n act = do
if n == 0 then do
return ()
else do
act
replicateM_ (n-1) act
This definition makes use of optional IO to decide when to stop, and recursion to continue performing the IO. We can now rewrite the previous example as:
main :: IO ()
main = do
let x = title "Welcome"
replicateM_ 3 x
In an imperative language the replicateM_ function is built in as a for statement, but the flexibility of Haskell allows us to define new control flow statements - a very powerful feature. The
replicateM_ function defined in
Control.Monad is like ours, but more general, and can be used instead.
IO in StructuresWe've seen IO values being passed as arguments, so it's natural that we can also put IO in structures such as lists and tuples. The function sequence_ takes a list of IO actions, and executes each action in turn:
sequence_ :: [IO ()] -> IO ()
sequence_ xs = do
if null xs then do
return ()
else do
head xs
sequence_ (tail xs)
If there are no elements in the list then sequence_ stops, with
return (). If there are elements in the list then sequence_ gets the first action (with
head xs) and executes it, then calls sequence_ on the remaining actions. As before,
sequence_ is available in
Control.Monad, but in a more general form. It is now simple to rewrite replicateM_ in terms of sequence_:
replicateM_ :: Int -> IO () -> IO ()
replicateM_ n act = sequence_ (replicate n act)
Pattern MatchingA much more natural definition of sequence_, rather than using null/head/tail, is to make use of Haskell's pattern matching. If there is exactly one statement in a do block, you can remove the do. Rewriting sequence_ we can eliminate the do after the equals sign, and the do after the then keyword.
sequence_ :: [IO ()] -> IO ()
sequence_ xs =
if null xs then
return ()
else do
head xs
sequence_ (tail xs)
Now we can replace the if with pattern matching, without needing to consider the IO:
sequence_ :: [IO ()] -> IO ()
sequence_ [] = return ()
sequence_ (x:xs) = do
x
sequence_ xs
Final ExampleAs a final example, imagine we wish to perform some operation on every file given at the command line. Using what we have already learnt, we can write:
main :: IO ()
main = do
xs <- getArgs
sequence_ (map operateFile xs)
operateFile :: FilePath -> IO ()
operateFile x = do
src <- readFile x
writeFile (x ++ ".out") (operate src)
operate :: String -> String
operate = ...
IO DesignA Haskell program usually consists of an outer IO shell calling pure functions. In the previous example main and operateFile are part of the IO shell, while operate and everything it uses are pure. As a general design principle, keep the IO layer small. The IO layer should concisely perform the necessary IO, then delegate to the pure part. Use of explicit IO in Haskell is necessary, but should be kept to a minimum - pure Haskell is where the beauty lies.
Where to go nowYou should now be equipped to do all the IO you need. To become more proficient I recommend any of the following:
- Write lots of Haskell code.
- Read chapters 8 and 9 of Programming in Haskell by Graham Hutton. You should expect to spend about 6 hours thinking and contemplating on sections 8.1 to 8.4 (I recommend going to a hospital A&E department with a minor injury).
- Read Monads as Containers, an excellent introduction to monads.
- Look at the documentation on the monad laws, and find where I've used them in this tutorial.
- Read through all the functions in Control.Monad, try to define them, and then use them when writing programs.
- Implement and use a state monad.