Part 5: IO and Explicit Effects

In this part of the course, we discuss Haskell code with side effects, such as terminal input and output, disk access, networking, random numbers, global state, and more. We motivate and introduce the IO type that is being used for side-effecting expressions. We learn how to compose IO actions, do notation, and how to combine the functional programming techniques we have been using so far with the world of IO.

Accompanying materials

5–1 Why Explicit Effects?

We discuss how it is problematic to tie the execution of side effects to evaluation, both because it violates equational reasoning, and because the effects become unpredictable due to lazy evaluation.

Self Test

  • Can you state two reasons why classic approaches to handling side effects are unsatisfactory in Haskell?

5–2 The IO Type

By introducing an abstract IO type for IO actions or plans, we solve the problem. Evaluating IO actions never executes any side effects. In other words, one can evaluate a plan without putting it into action. Execution instead happens via either making an IO action part of the main entry point to a program, or alternatively, by asking GHCi to execute an IO action for us.

Self Test

  • In what way is the IO type different from most other datatypes that we have been discussing so far? (I.e., from Maybe, [], Either, all sorts of tree types …)

  • What is the type of the following expression and what does it evaluate to? Is anything printed or being read, and if so, when?

    fst (length [getLine, getLine], putStrLn "Hello")
  • Using a single putStrLn call in GHCi, print a string that spans three lines.

  • Read the contents of a file using readFile.

  • What does getChar do? (Look at its type, try it out; remember you can also use the :doc command in GHCi.)

  • Why does putStrLn have the type String -> IO () and not the type IO (String -> ())?

  • In the module System.Environment, there is an action getExecutablePath. When executed, it should normally return the file path to the Haskell application you are currently running. If executed from within GHCi, it should point to the GHC executable. Try this.

  • Also in the module System.Environment, there is a function lookupEnv :: String -> IO (Maybe String) that looks up the value of operating system environment variables. Explain why this has the result type IO (Maybe String) as opposed to Maybe String or even String. Try to use this function in GHCi to obtain your current search path by calling lookupEnv "PATH".

5–3 Sequencing

We want to create more complex IO actions by combining smaller actions. We introduce a few functions to do so, such as the basic sequencing operator (>>) and the function liftA2 that sequences two actions and gives us flexibility in how we want to combine their results. We also revisit the problematic examples from 5–1 and see that we can now precisely define all the variants, and equational reasoning is still maintained.

Self Test

  • What is the type of the following expression and what happens if you type it into GHCi? (Predict the result before trying.)

    snd (putStrLn "Please provide some input:", getLine)
  • What does getLine ++ getLine do, and why? (Predict the result before trying.)

  • What is the result of the following expression and what happens if you type it into GHCi? (Predict the result before trying.)

    uncurry (>>) (getLine, getLine)
  • Define an action that reads three lines and has the last line read as its result.

  • Use fmap to define an action countChars :: IO Int that reads a line from the input and returns the number of characters that have been typed in.

  • Note that Control.Applicative also defines a function called liftA3. Use this function to define an action that reads three lines and keeps only the second line as the result, discarding the other two.

5–4 Bind

The most general sequencing operator for IO actions is called “bind” and written (>>=). We also introduce return that lifts any computation into the world of IO actions. Using return and (>>=), we can reimplement all the other operations on IO we have introduced so far, in particular (>>), fmap and liftA2.

Self Test

  • Define a function sizeOfFile :: FilePath -> IO () that reads the file of the given name and prints to the screen the number of characters contained in that file.

  • Define a function forever :: IO a -> IO b that takes an IO action and repeats it forever, so it never produces a final result. (Can you see from the type of forever that it cannot terminate?) Then try, for example forever (putStrLn "Hello"). Remember you can terminate computations in GHCi with Ctrl+C.

  • Provide a definition of

    liftA3 :: (a -> b -> c -> d) -> IO a -> IO b -> IO c -> IO d

    using only return and `(>>=).

5–5 do Notation

We introduce do notation, a syntactic construct that allows us to write sequences of IO actions in a way that resembles imperative programs more closely.

Self Test

  • How can the following bindings using do notation be simplified?

    example1 :: IO ()
    example1 = do
      putStrLn "!"
    
    example2 :: IO String
    example2 = do
      _ <- getLine
      y <- getLine
      return y
    
    example3 :: IO ()
    example3 = do
      x <- do
        putStrLn "? "
        getLine
      putStrLn x
  • Rewrite forever and liftA3 from the previous Self Test using do notation.

5–6 Mapping and Filtering with IO Actions

Using higher-order functions in the context of IO operations is possible, but requires some new ingredients. We discuss the function sequence that can turn a list of IO actions into a single action returning a list, and how sequence can be used in order to define a variant of map that is suitable if the function we want to map with results in an IO action. We also discuss a variant of filter for filtering with an IO-based test.

Self Test

  • The function print is a composition of putStrLn and show. It is the function GHCi uses internally to print back the result of non-IO expressions you type in. Redefine this function yourself. Use it to print an integer and a string.

  • Look at the types of mapM and mapM_. Keep in mind that where the class constraint is Foldable or Traversable, you can think of lists, and where the constraint says Monad, you can think IO. Can you see in what way mapM_ is a special case of mapM? Use mapM_ to print all the integers from 1 to 10 to the screen, each on one line.

5–7 Excursion: Working with Packages

So far, all functions we have been using in our Haskell code have been coming from the base package that ships with GHC. We have now reached a point where we would like to use other packages that come from Hackage, the Haskell package repository. We show how we can turn our own developments into packages by defining a .cabal package description file, and how in doing that, we can specify dependencies on other Haskell packages. Tools such as the cabal command line tool or Haskell Language Server can then interpret these package description files for us, install missing dependencies, and invoke GHC in such a way that it can find all the modules we use in our code.

Self Test

  • Create a new Haskell package in a new directory using cabal init. Make it successfully depend on the random package. Use randomRIO to create a random number between 1 and 10.

  • Make your package successfully depend on the time package, import the Data.Time module and try to successfully execute getCurrentTime to obtain the current system time. Also execute getCurrentTimeZone to check the time zone your computer thinks you are in.

5–8 File Operations and Exceptions

We give a few examples of functionality we can use now that we have packages such as directory available. We show how to list all files in a directory and determine their sizes. We also discuss exceptions in the context of IO actions. What happens if we access a file that does not exist, or we do not have permission to access? How can we handle such exceptions?

Self Test

  • The module System.Directory contains an action getHomeDirectory the, when executed, should determine the “home directory” of the current user. Try to execute this action in GHCi.

  • The is a function getAccessTime that determines the time of last access for a file. Define a Haskell program that lists the access times for all files in the current directory.

  • Define Haskell code that creates a new directory test in the current directory and creates a file test.txt containing the string "Hello" in that directory.

5–9 Interaction Trees

When doing larger Haskell developments involving side-effecting operations, it becomes important how we structure our code. If we are not careful, it can easily become that case that we have IO types everywhere, and that is undesirable. We demonstrate using the example of expert systems / interaction trees that often one can take a data-centric view on a system and define the core logic of a system in such a way that no side effects are involved at all.

A layer that adds the interactive components around the core logic is then one of many possible interpretations of such a system, but other interpretation functions are possible, such as simulations that are useful for testing with low overhead.

Self Test

  • On the datatype of interaction trees as given in the video, define a function longestPath :: Interaction -> Int that computes the maximum amount of questions that a single interaction can contain.

  • You can use the action randomIO at type IO Bool to obtain a random Bool. Define a function

    interactRandomly :: Interaction -> IO String

    that performs a random walk through the given interaction tree and returns the final answer.

  • An equivalent representation of interaction trees is:

    data Interaction' =
        Question' String (Bool -> Interaction')
      | Result' String

    Rewrite some of the functions operating on interaction trees to work with this version instead.

    Also try to define functions

    fromInteraction :: Interaction -> Interaction'
    toInteraction :: Interaction' -> Interaction

    to witness the isomorphism between (i.e., equivalence of) the two types.

5–10 unsafePerformIO

There is essentially no function of type IO a -> a. It is understandable you may occasionally feel like you need this, but the correct answer is nearly always: start a sequence of IO actions, because (>>=) temporarily gives you access to the result of the previous computation. But you cannot completely forget that a particular value is depending on side effects. If you do, unpredictable behaviour can occur, somewhat akin to the experiments we discussed in the beginning of this part, in video 5–1. A function of the given type does actually exist under the name of unsafePerformIO, but it should not be used except in very narrow use cases that are far beyond what we have been discussing so far.

Self Test

  • Just for the fun of it, define

    getLine' :: String
    getLine' = unsafePerformIO getLine

    (you have to import System.IO.Unsafe) and then produce the three sample programs from Video 5–1 using getLine' rather than getLine, and try them out. What are you observing?

  • There is a useful debugging function defined in terms of unsafePerformIO. It is called

    trace :: String -> a -> a

    and defined in the Debug.Trace module. The expression trace msg x prints the string msg to the screen when x is evaluated, without there being an IO tag. This can be helpful for debugging occasionally, but it still suffers from being quite unpredictable. Try to predict the results of the following expressions and then try them:

    take 2 (map (trace ".") [1..10])
    length (map (trace ".") [1..10])
    length (trace "." [1..10])
    filter (trace "." even) [1..10])
    filter (\ x -> trace "." (even x)) [1..10]
    replicate (trace"x" 5) (trace "y" '!')

5–11 Assignments E

The assignments for this part of the course also make use of other packages than just base. Therefore, the assignments do not just consist of a single Haskell source file as usual up until now, but also a .cabal file specifying package and dependency information, and a hie.yaml file to explicitly specify to Haskell Language Server how the source file should be built. This means in particular that you should not just call ghci on the source file, but instead use cabal repl to get the file loaded into GHCi for you with the correct configuration. Make sure you have watched video 5–7 that explains all this.

Because there are several files, this assignment is distributed as a zip file here.

You can unpack this file into a local directory and then work on the contents in your editor and using cabal repl. As usual, the source file contains several skeletons of functions that you are supposed to complete, as well as a few other questions.

Note that in this public version of the course, there is no way to submit the assignments.

5–12 Optional: Assignments X

In this optional set of assignments, you can work on a slightly larger connected task, that, if completed, will give you a reimplementation of the Mastermind board game. The task is still broken down into a large number of small tasks you can solve in the usual style, and the tasks recap all the topics covered in this course up to this point, i.e., there is only a relatively small layer of IO involved, following to some extent the philosophy described in video 5–9.

As the game needs to make use of random numbers, it depends on the random package, and therefore also comes with a .cabal file and is distributed as a zip file here.

You can unpack this file into a local directory and then work on the contents in your editor and GHCi. As usual, the source file contains several skeletons of functions that you are supposed to complete, as well as a few other questions.

Note that in this public version of the course, there is no way to submit the assignments.

Continue this course

Next: Part 6: Monads.