Haskell I / O explanation without monads

Original author: Neil Mitchell
  • 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”]

Main functions


In this tutorial, we will use four standard I / O functions:

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 atakes 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 dousing 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 returnfor 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:
  1. Instead, _ <- xyou can write simply x.
  2. If there is no connecting arrow ( <-) on the penultimate line and the expression is of type IO (), then the last line with return ()can be deleted.
  3. x <- return ycan be replaced by let 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 mainhas 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 returnin imperative languages, this returnshould 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 ifwe 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 elsehave 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 titleabove, 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 IOin a variable x. xhas a type IO (), so now we can write хin a string to perform the action written in it. By writing xthree 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 forin 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 xsand 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 dothere is exactly one instruction in the block, then the word docan 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 ifwith 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, mainand operateFileare part of the shell, operateand 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 .

Also popular now: