Haskell I / O explanation without monads
- Transfer
This article explains how to do input and output in Haskell without trying to give any understanding about monads in general. We will start with the simplest example, and then gradually move on to more complex ones. You can read the article to the end, or you can stop after any section: each subsequent section will allow you to cope with new tasks. We assume familiarity with the basics of Haskell, in the volume of chapters 1 through 6 of the book "Programming in Haskell" by Graham Hatton . [Note translator: chapters “Introduction”, “First steps”, “Types and classes”, “Definition of functions”, “Selections from lists”, “Recursive functions”]
In this tutorial, we will use four standard I / O functions:
The simplest useful form of I / O: read a file, do something with its contents, and then write the results to a file.
This program reads file.in, performs the function operate on its contents, and then writes the result to file.out. The main function contains all the I / O, and the operate function is pure . When writing operate, you don’t need to understand any I / O details. The first two years of programming in Haskell, I used only this model and it was quite enough.
If the template described in the previous section is not enough for your tasks, then the next step is to use the list of actions. The main function can be written like this:
First comes the keyword
As a simple example, we can write a program that receives command line arguments, reads the file specified by the first argument, works with its contents, and then writes to the file specified by the second argument:
This action list template is very tough, and people usually simplify the code with the following three rules:
Using these rules, we can rewrite our example:
So far, only a function
We can use this function several times inside
So far, all the functions that we wrote have been of type IO (), which allows us to perform I / O, but does not allow us to produce interesting results. To give a value
This function returns the first two arguments of the command line, or default values if there were less than two arguments on the command line. Now we can use it in our program:
Now, if less than two arguments are given, the program will not crash, but will use the default file names.
So far, we have only seen a static list of I / O instructions executed in order. With the help
To select actions, the last instruction in the
If you started reading without knowing the I / O in Haskell, and got to here, then I advise you to take a break (drink tea and cake; you deserve it). The functionality described above is all that imperative languages allow you to do, and it is a useful starting point. Just as functional programming provides much more efficient ways to work with functions, considering them as values, it allows us to consider both values and I / O actions, which we will deal with in the rest of this article.
Until now, all instructions have been executed immediately, but we can also create variables of type IO. Using the function
Instead of performing an action through
We can also pass
Here we used a selection of actions to decide when to stop, and a recursion to continue execution. Now we can rewrite the previous example in this way:
Of course, the instruction
We have seen how
If there are no elements in the list, then
In Haskell, it’s far more natural to use pattern matching than
Now we can replace it
As a final example, imagine that we want to perform some operations with each file specified on the command line. Using what we have learned, we can write:
A Haskell program usually consists of an external action wrapper that calls pure functions. In the previous example,
Now you are ready to do any I / O that your program will need. To strengthen my skills, I recommend doing something from the following list:
Main functions
In this tutorial, we will use four standard I / O functions:
readFile :: FilePath -> IO String
- read filewriteFile :: FilePath -> String -> IO ()
- write to filegetArgs :: IO [String]
- getting command line arguments (from a moduleSystem.Environment
)putStrLn :: String -> IO ()
- output the line, and transfer the line after it to the console
Simple I / O
The simplest useful form of I / O: read a file, do something with its contents, and then write the results to a file.
main :: IO () main = do src <- readFile "file.in" writeFile "file.out" (operate src) operate :: String -> String operate = ... is your function
This program reads file.in, performs the function operate on its contents, and then writes the result to file.out. The main function contains all the I / O, and the operate function is pure . When writing operate, you don’t need to understand any I / O details. The first two years of programming in Haskell, I used only this model and it was quite enough.
Action list
If the template described in the previous section is not enough for your tasks, then the next step is to use the list of actions. The main function can be written like this:
main :: IO () main = do x1 <- expr1 x2 <- expr2 ... xN <- exprN return ()
First comes the keyword
do
, then a sequence of instructions xI <- exprI
, and everything ends return ()
. In each instruction, to the left of the arrow is a sample (most often just a variable) of a certain type t
, and to the right is an expression of the type IO t
. Variables associated with the sample can be used in subsequent instructions. If you want to use an expression whose type is different from IO t
, then you need to write xI <- return (exprI)
. The function return :: a -> IO a
takes any value and "wraps" it into an IO type. As a simple example, we can write a program that receives command line arguments, reads the file specified by the first argument, works with its contents, and then writes to the file specified by the second argument:
main :: IO () main = do [arg1, arg2] <- getArgs src <- readFile arg1 res <- return (operate src) _ <- writeFile arg2 res return ()
operate
- still a pure function. The first line after do
using pattern matching retrieves the command line arguments. The second line reads the file whose name is specified in the first argument. The third line uses return
for pure value operate src
. The fourth line writes the result to a file. This does not give any useful result, so we ignore it by writing _ <-
.Simplify I / O
This action list template is very tough, and people usually simplify the code with the following three rules:
- Instead,
_ <- x
you can write simplyx
. - If there is no connecting arrow (
<-
) on the penultimate line and the expression is of typeIO ()
, then the last line withreturn ()
can be deleted. x <- return y
can be replaced bylet x = y
(if you are not reusing variable names).
Using these rules, we can rewrite our example:
main :: IO () main = do [arg1, arg2] <- getArgs src <- readFile arg1 let res = operate src writeFile arg2 res
Nested I / O
So far, only a function
main
has an IO type, but we can create new functions of this type to avoid code repetition. For example, we can write a helper function to print beautiful headers:title :: String -> IO () title str = do putStrLn str putStrLn (replicate (length str) '-') putStrLn ""
We can use this function several times inside
main
:main :: IO () main = do title "Hello" title "Goodbye"
Return Values in IO
So far, all the functions that we wrote have been of type IO (), which allows us to perform I / O, but does not allow us to produce interesting results. To give a value
х
, we write in the last line of the do
-block return x
. Unlike instructions return
in imperative languages, this return
should be on the last 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 arguments of the command line, or default values if there were less than two arguments on the command line. Now we can use it in our program:
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 not crash, but will use the default file names.
Select I / O Actions
So far, we have only seen a static list of I / O instructions executed in order. With the help
if
we can choose what actions to perform. For example, if the user has not entered any arguments, we can report this:main :: IO () main = do xs <- getArgs if null xs then do putStrLn "You entered no arguments" else do putStrLn ("You entered" ++ show xs)
To select actions, the last instruction in the
do
-block needs to be done if
, and continue in each of its branches do
. The only subtle point is that you else
have to indent at least one space more than if
. This is widely regarded as a mistake in the definition of Haskell, but at the moment, this extra space is indispensable.Respite
If you started reading without knowing the I / O in Haskell, and got to here, then I advise you to take a break (drink tea and cake; you deserve it). The functionality described above is all that imperative languages allow you to do, and it is a useful starting point. Just as functional programming provides much more efficient ways to work with functions, considering them as values, it allows us to consider both values and I / O actions, which we will deal with in the rest of this article.
Work with IO values
Until now, all instructions have been executed immediately, but we can also create variables of type IO. Using the function
title
above, we can write:main :: IO () main = do let x = title "Welcome" x x x
Instead of performing an action through
<-
, we put the value itself IO
in a variable x
. x
has a type IO ()
, so now we can write х
in a string to perform the action written in it. By writing x
three times, we perform this action three times.Passing Actions as Arguments
We can also pass
IO
-values as arguments to functions. In the previous example, we performed the action title "Welcome"
three times, but how could we perform it fifty times? We can write a function that takes an action and a number, and performs this action an appropriate number of times:replicateM_ :: Int -> IO () -> IO () replicateM_ n act = do if n == 0 then do return () else do act replicateM_ (n-1) act
Here we used a selection of actions to decide when to stop, and a recursion to continue execution. Now we can rewrite the previous example in this way:
main :: IO () main = do let x = title "Welcome" replicateM_ 3 x
Of course, the instruction
for
in imperative languages allows you to do the same as the function replicateM_
, but the flexibility of Haskell allows you to define new control instructions - a very powerful tool. The function replicateM_
defined in Control.Monad is similar to ours, but more general; so you can use it instead of our option.IO in data structures
We have seen how
IO
-values are passed as an argument, so it is not surprising that we can put them in data structures, such as lists and tuples. The function sequence_
takes a list of actions and executes them 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_
terminates with return ()
. If there are elements in the list, then sequence_
selects the first action with head xs
and performs it, and then calls it sequence_
on the rest of the list tail xs
. Like replicateM_
, it is sequence_
already present in Control.Monad in a more general way. Now you can easily rewrite replicateM_
using sequence_
:replicateM_ :: Int -> IO () -> IO () replicateM_ n act = sequence_ (replicate n act)
Pattern Matching
In Haskell, it’s far more natural to use pattern matching than
null/head/tail
. If do
there is exactly one instruction in the block, then the word do
can be removed. For example, in the definition, sequence_
this can be done after the equal sign and after then
.sequence_ :: [IO ()] -> IO () sequence_ xs = if null xs then return () else do head xs sequence_ (tail xs)
Now we can replace it
if
with a comparison, as in any similar situation, without worrying about IO
:sequence_ :: [IO ()] -> IO () sequence_ [] = return () sequence_ (x: xs) = do x sequence_ xs
Last example
As a final example, imagine that we want to perform some operations with each file specified on the command line. Using what we have learned, 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 = ...
Designing I / O in a program
A Haskell program usually consists of an external action wrapper that calls pure functions. In the previous example,
main
and operateFile
are part of the shell, operate
and all the functions that it uses are pure. As a general design principle, try to make the action layer as thin as possible. The shell should briefly perform the necessary input, and lay the main work on the clean part. Using explicit I / O in Haskell is necessary, but it should be minimized - pure Haskell is much prettier.What's next
Now you are ready to do any I / O that your program will need. To strengthen my skills, I recommend doing something from the following list:
- Write a lot of code in Haskell.
- Read chapters 8 and 9 of “Programming in Haskell”. Expect to spend about 6 hours thinking about sections 8.1 to 8.4 (it would be nice to go to the hospital with a slight injury).
- Read Monads as containers , an excellent introduction to monads.
- Look at the documentation on the laws of monads , and find where I used them in this article.
- Read the documentation of all the functions in Control.Monad , try to implement them, and then use them when writing programs.
- Implement and use the state monad .