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., fromMaybe
,[]
,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 typeString -> IO ()
and not the typeIO (String -> ())
?In the module
System.Environment
, there is an actiongetExecutablePath
. 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 functionlookupEnv :: String -> IO (Maybe String)
that looks up the value of operating system environment variables. Explain why this has the result typeIO (Maybe String)
as opposed toMaybe String
or evenString
. Try to use this function in GHCi to obtain your current search path by callinglookupEnv "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 actioncountChars :: 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 calledliftA3
. 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 anIO
action and repeats it forever, so it never produces a final result. (Can you see from the type offorever
that it cannot terminate?) Then try, for exampleforever (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 () = do example1 putStrLn "!" example2 :: IO String = do example2 <- getLine _ <- getLine y return y example3 :: IO () = do example3 <- do x putStrLn "? " getLine putStrLn x
Rewrite
forever
andliftA3
from the previous Self Test usingdo
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 ofputStrLn
andshow
. 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
andmapM_
. Keep in mind that where the class constraint isFoldable
orTraversable
, you can think of lists, and where the constraint saysMonad
, you can thinkIO
. Can you see in what waymapM_
is a special case ofmapM
? UsemapM_
to print all the integers from1
to10
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 therandom
package. UserandomRIO
to create a random number between1
and10
.Make your package successfully depend on the
time
package, import theData.Time
module and try to successfully executegetCurrentTime
to obtain the current system time. Also executegetCurrentTimeZone
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 actiongetHomeDirectory
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 filetest.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 typeIO Bool
to obtain a randomBool
. Define a functioninteractRandomly :: 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 usinggetLine'
rather thangetLine
, and try them out. What are you observing?There is a useful debugging function defined in terms of
unsafePerformIO
. It is calledtrace :: String -> a -> a
and defined in the
Debug.Trace
module. The expressiontrace msg x
prints the stringmsg
to the screen whenx
is evaluated, without there being anIO
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.