Understanding IO
Performing IO is essential to the function of almost any application. Even a simple “hello world” application must be able to perform IO to run. Haskell’s approach to IO is significantly different than most other languages. The way that Haskell approaches IO is a gateway into one of the most useful and challenging parts of learning HaHskell.
You’ll learn what IO actions are and how to combine them. You’ll also be introduced to monads and learn some of the most important operations that you can do with them.
You’ll be able to write functions that perform IO, and combine those functions so that they run in a specific well defined sequence when you run your program.
In the next chapter you’ll use what you’ve learned in this chapter to build a fully working interactive command line application that reads files from disk, interacts with the keyboard, and prints information to the screen.
Thinking About IO Types
This problem is presented in three parts. Hints and solutions are provided for each part individually, so that you can get hints or view the solution to one problem without seeing the solution to the others.
The Type of Nested IO Actions
Write a function that returns a value of type IO (IO String)
. What happens if
you try to use (>>=)
with that? What if you want to print the string?
Hints
Click to reveal
Remember the types of return
and >>=
:
return :: Monad m => a -> m a
(>>=) :: m a -> (a -> m b) -> m b
Click to reveal
Keep in mind that when you see type variables like m a
that a
can be any
type, including IO String
.
Solution
Click to reveal
The easiest way to intentionally create a value with the type IO (IO String)
is to use return
twice:
doubleIO :: IO (IO String)
= return $ return "hello" doubleIO
In practice though, you probably won’t create values like this on
purpose. You’re more likely to create a value with a type like IO (IO String)
on accident by using return
with a call to a function that already returns an
IO action. For example:
returnRead :: IO (IO String)
= return $ readFile "/tmp/example" returnRead
However we end up with the value, if we want to use (>>=)
with a nested IO
action, it’s important to realize that we’ll only be dealing with the outer
layer of IO
. To understand what that means, let’s take a look at the type of
(>>=)
:
(>>=) :: m a -> (a -> m b) -> m b
Next, let’s replace the type variables with our own types to help get a better
idea of what’s happening. In our case, m
will become the outer IO
of our
nested IO action, and a
will be the inner IO action with the type IO String
. If we substitute these types in for the type variables we’ll end up
with:
(>>=) :: IO (IO String) -> (IO String -> IO b) -> IO b
You’ll notice that we still have a type variable, b
. Importantly, that means
we can use (>>=)
with any function that returns an IO action, not necessarily
only nested IO actions. This is important when we look at the last part of our
question: how should we print a value inside of a nested IO action, like IO (IO String)
.
Let’s start by looking at one solution, then we’ll step back a bit to untangle how and why it works:
printNestedIO :: IO (IO String) -> IO ()
= (>>= (>>= putStrLn)) printNestedIO
At first glance this is pretty hard to read! The only thing that’s apparent is
that we’re calling (>>=)
twice- which isn’t entirely unsurprising since we’re
dealing with two nested layers of IO
, but it’s not entirely readable either.
Part of the problem is that we’ve gone a bit too far with making our code entirely point-free. Let’s change this by adding a variable to hold our nested IO action:
printNestedIO :: IO (IO String) -> IO ()
= nestedIO >>= (>>= putStrLn) printNestedIO nestedIO
This is a little better, but it’s still kind of hard to read. Let’s replace
(>>= putStrLn)
with a helper function:
printNestedIO :: IO (IO String) -> IO ()
= nestedIO >>= go
printNestedIO nestedIO where
go :: IO String -> IO ()
= ioString >>= putStrLn go ioString
That’s more readable! This refactored version of our code helps make it a bit more apparent what’s happening. We can read this version of our code in two parts. First, the outer part:
= nestedIO >>= go printNestedIO nestedIO
In this function we’re working with the outer IO action. In our call to (>>=)
we can fill in the relevant type variables to see this:
(>>=) :: m (a ) -> (a -> m b ) -> m b
= IO (IO String) -> (IO String -> IO ()) -> IO ()
m :: IO
a :: IO String
a :: IO (IO String)
mb :: ()
b :: IO () m
Going down a level, the helper function go
is how we handle our inner IO
action. Let’s once again fill in the types for our call to (>>=)
:
(>>=) :: m a -> (a -> m b ) -> m b
= IO String -> (String -> IO ()) -> IO ()
m :: IO
a :: String
a :: IO String
mb :: ()
b :: IO () m
This inner function does the work of taking our string and printing it out. In the next section
A Function From Nested IO Actions
Using your function from the previous example, create a function that has the
type signature: IO (IO a) -> IO a
.
Hints
Click to reveal
In the last part of this exercise, you wrote a function to print a value with
the type IO (IO String)
. Think about how you can generalize this to returning
the value rather than printing it.
Solution
Click to reveal
Although this problem might seem puzzling at first, it turns out that the
solution is pretty straightforward, and it will probably look familiar if you’ve
already solved the previous part of this problem. Let’s start solving this
problem by creating a function called joinIO
and leaving the definition
undefined
:
joinIO :: IO (IO a) -> IO a
= undefined joinIO ioAction
From our experience with IO
so far, we know that (>>=)
is pretty important,
and we’ve seen the last exercise that it gives us a way to get to the inner part
of a nested pair of IO actions. It’s clear that we’ll want pass our nested
ioAction
into (>>=)
but what should be on the other side? Let’s try adding a
type hole to see if the compiler can help us out:
joinIO :: IO (IO a) -> IO a
= ioAction >>= _ joinIO ioAction
If we run this, the compiler will give us some useful information:
/EffectiveHaskell/Exercises/Chapter7/Join.hs:4:32: error: …
srcFound hole: _ :: IO a -> IO a
• Where: ‘a’ is a rigid type variable bound by
type signature for:
the joinIO :: forall a. IO (IO a) -> IO a
/home/rebecca/projects/effective-haskell.com/solution-code/src/EffectiveHaskell/Exercises/Chapter7/Join.hs:3:1-27
at In the second argument of ‘(>>=)’, namely ‘_’
• In the expression: ioAction >>= _
In an equation for ‘joinIO’: joinIO ioAction = ioAction >>= _
Relevant bindings include
• ioAction :: IO (IO a)
/home/rebecca/projects/effective-haskell.com/solution-code/src/EffectiveHaskell/Exercises/Chapter7/Join.hs:4:8)
(bound at joinIO :: IO (IO a) -> IO a
/home/rebecca/projects/effective-haskell.com/solution-code/src/EffectiveHaskell/Exercises/Chapter7/Join.hs:4:1)
(bound at Valid hole fits include
id :: forall a. a -> a
id @(IO a)
with Prelude’ at /home/rebecca/projects/effective-haskell.com/solution-code/src/EffectiveHaskell/Exercises/Chapter7/Join.hs:1:8-47
(imported from ‘and originally defined in ‘GHC.Base’))
(|
Compilation failed.
The important part of this message is that we need to fill the type hole we
created with something of type IO a -> IO a
. Your first reaction might be to
write a function with this type as a helper. Let’s call it returnInnerIO
:
joinIO :: IO (IO a) -> IO a
= ioAction >>= returnInnerIO
joinIO ioAction where
returnInnerIO :: IO a -> IO a
= a >>= return a returnInnerIO a
This works just as you’d expect, and we can test it out in ghci:
:t joinIO (return $ return "hello")
λ return $ return "hello") :: IO String
joinIO (
return $ return "hello") >>= putStrLn
λ joinIO ( hello
Even though this works, we’re doing more work than we need to. For one thing,
the definition of returnInnerIO
is essentially creating a new IO action that
runs the first IO action and then returns it’s value. We can skip all of that
extra work and simply return a
directly:
joinIO :: IO (IO a) -> IO a
= ioAction >>= returnInnerIO
joinIO ioAction where
returnInnerIO :: IO a -> IO a
= a returnInnerIO a
This is a bit better, and you can verify in ghci
that it still works as
expected, but it turns out that this is still an unnecessary amount of work. You
might recognize that returnInnerIO
is the same as another function you’ve
already seen:
id :: a -> a
id a = a
Remember that when you’re dealing with polymorphic functions like id
, they can
work on types like IO a
just as well as types like Int
or String
. Let’s do
another refactor of our solution to remove returnInnerIO
altogether and
replace it with id
:
joinIO :: IO (IO a) -> IO a
= ioAction >>= id joinIO ioAction
Once again, you can test this out inghci
to validate that it’s still working
as expected.
If you’re using a linting tool like
hlint, or an editor with hlint
integration built in, you might notice that there’s one more refactor that we
can do. It turns out that we’ve rewritten a standard library function named
join
. You’ll learn more about join
in Chapter 9 of Effective Haskell, but if
you want a quick preview, you can see that we can use it in exactly the same way
that we’ve been using joinIO
:
import Control.Monad (join)
λ return $ return "hello") >>= putStrLn
λ join ( hello
Lists of IO Actions
Write a function that returns a value of type [IO a]
, and a second function
with the type [IO a] -> IO [a]
. When might you use a function like that?
Hints
Click to reveal
Remember, you can make recursive calls inside of IO actions.
Click to reveal
You’ll need to evaluate all of the IO
actions before you can return a list
with the results.
Solution
Click to reveal
It turns out that functions with types like [IO a] -> IO [a]
are useful and
come up regularly in all sorts of Haskell programs. There are some general
purpose functions that you’ll learn about later in the book that will teach you
how to work with functions that are a bit more general than what we’ll cover in
this exercise. For now, let’s focus on the question at hand. We can write a
function called sequenceIO
that takes a list of IO actions and returns a
single IO action that returns a list with all of the values.
Let’s start with the easiest scenario: If the input is an empty list, we can
just return
an empty list. The non-empty list case is a bit more complicated,
so we’ll leave it undefined
for now:
sequenceIO :: [IO a] -> IO [a]
= return []
sequenceIO [] :xs) = undefined sequenceIO (x
In the non-empty case we’ve pattern matched the first IO action out from our
list. Let’s ignore the rest of the list for now, and think about how we can
return a list with just this element, with the right type. We’ll need to
evaluate the IO action, so we’ll probably want to use >>=
:
sequenceIO :: [IO a] -> IO [a]
= return []
sequenceIO [] :xs) = x >>= \x' -> undefined sequenceIO (x
In this example, x'
will contain the result of evaluating the IO action in
x
. If we don’t care about the rest of the list, we can simply create a new
list that holds this value and return
it:
sequenceIO :: [IO a] -> IO [a]
= return []
sequenceIO [] :xs) = x >>= \x' -> return [x] sequenceIO (x
Of course, this will only give us back the first IO action in our list. If we
want everything, we’ll need to sequence the rest of the list as well. How should
we do that? We know that xs
has the type [IO a]
. If we recursively pass xs
to sequenceIO
we can convert that to a type of IO [a]
. Let’s add that as a
where binding for now, and then think about what to do next:
sequenceIO :: [IO a] -> IO [a]
= return []
sequenceIO [] :xs) = x >>= \x' -> return [x']
sequenceIO (xwhere rest = sequenceIO xs
Next, let’s combine the result of our recursive call with x'
. To do that,
we’ll need to get at the value inside of rest
. We can use (>>=)
to help us
again. Since it will type check whether we do our recursive call first or last,
let’s start with rest
:
sequenceIO :: [IO a] -> IO [a]
= return []
sequenceIO [] :xs) = rest >>= \rest' -> x >>= \x' -> return $ x' : rest'
sequenceIO (xwhere rest = sequenceIO xs
> sequenceIO $ map print [1..10]
λ10
9
8
7
6
5
4
3
2
1
[(),(),(),(),(),(),(),(),(),()]
That doesn’t look quite right! We would have expected our numbers to be printed
out in ascending order, but in this test we’re seeing them printed in reverse
order. It turns out that our choice to put rest
first has a big impact. When
we pass rest
into (>>=)
we have to strictly evaluate the IO action. That
means we’re going to
In this example we’re creating a new IO action that first runs the IO action at
the head of our list, then recursively runs the IO actions in the remainder of
the list. Finally, it returns the result of our initial IO action cons-ed onto
the result of the IO action that computes the remainder of the list. It type
checks, but let’s load up ghci
to see if it actually works.
$ map print [1..10]
λ sequenceIO 1
2
3
4
5
6
7
8
9
10
[(),(),(),(),(),(),(),(),(),()]
At first glance, this might be a little confusing. Let’s try it again with some intermediate values to help understand what’s happening:
= map print [1..10]
λ printUpToTen :t printUpToTen
λ printUpToTen :: [IO ()]
:t sequenceIO printUpToTen
λ printUpToTen :: IO [()]
sequenceIO
λ sequenceIO printUpToTen1
2
3
4
5
6
7
8
9
10
[(),(),(),(),(),(),(),(),(),()]
That’s better! Since all of our calls to print
are going to return IO ()
,
after calling sequenceIO
we’re going to be let with a list of plain ()
values. When we call sequenceIO
we’re first seeing the side effects of each
number being printed, and the final line is the value returned by the function,
which is a list of ()
values. Getting back a list of results is a little
annoying for examples like this one, where we’re using IO actions entirely for
their side effects. Let’s write a helper function that discards the return
value. We’ll follow a common Haskell convention and add an underscore suffix at
the end of our function name to indicate that it ignores its result:
sequenceIO_ :: [IO a] -> IO ()
= sequenceIO actions >> return () sequenceIO_ actions
If we test this, you’ll see that ghci
helpfully ignores the final ()
values
and works like we’d expect any sort of print
-like function to work:
$ map print [1..10]
λ sequenceIO_ 1
2
3
4
5
6
7
8
9
10
So far, so good, but there are a few things worth spending some time on before
we finish up this exercise. First, there’s a subtlety in our implementation that
we should take some time to investigate fruther. Second, there’s an opportunity
to rewrite our code to be much easier to read using do
notation.
Remember that whenever we have code like this:
>>= \b -> doSomethingWith b a
We can rewrite it with do
notation like this:
do
<- a
b doSomethingWith b
This isn’t always more readable, but it can be really helpful in situations like
our implementation of sequenceIO
where we need to run two IO actions and get
their results before we can move on. Let’s give it a try:
sequenceIO :: [IO a] -> IO [a]
= return []
sequenceIO [] :xs) = do
sequenceIO (x<- x
x' <- sequenceIO xs
xs' return $ x' : xs'
Building A Command Line Calculator
First, Write a program that reads in numbers from the command line and prints the sum of the provided values.
Next, Modify the program so that the first argument is an operation (+
, -
,
or *
) and performs the supplied operation on the list of numbers.
Hints
Click to reveal
Remember that you can get the command line arguments with the getArgs
function
from System.Environment
. You can test programs that use getArgs
in ghci
using the withArgs
function. For example, let’s write a function that prints
out all of the arguments passed in:
printArgs :: IO ()
= getArgs >>= print printArgs
We can load this up in ghci
and test it with withArgs
by passing in a list
of the arguments that getArgs
should return:
"hello", " ", "world"] printArgs
λ withArgs ["hello"," ","world"] [
Click to reveal
You’ll need to use read
to convert the String
values that getArgs
returns
into numbers that you can add together.
Click to reveal
You can pattern match while getting a value from an IO action in do
notation. For example, if you wanted to get the first argument, and the
remaining arguments, you can write:
:rest) <- getArgs (first
Let’s look at an example where we print out the first argument, then the remaining arguments:
printArgs :: IO ()
= do
printArgs :rest) <- getArgs
(firstputStrLn $ "first: " <> first
putStrLn $ "rest: " <> show rest
This works as long as we pass in at least one argument:
"first"] printArgs
λ withArgs [: first
first: []
rest
"first", "second", "third"] printArgs
λ withArgs [: first
first: ["second","third"] rest
But be careful! Like all pattern matching, this is partial and will fail if we don’t pass in any arguments:
> withArgs [] printArgs
λ*** Exception: user error (Pattern match failure in 'do' block at ...)
Solution
Click to reveal
This exercise asks us to solve two different problems: First, we’d like to add up all of the numbers passed in as command line arguments, next we’d like to let the user pick an operation other than addition. We’ll take this solution in two parts. First, let’s work on simply adding up the numbers passed in as command line arguments.
To do this, we’ll need to:
- get the command line arguments
- conver them to numbers
- add the list of numbers
- print it out
There are a couple of ways to solve this. One choice we’ll need to make is
whether we’d like to use (>>=)
or do
notation. Let’s look at both options,
starting with (>>=)
. We can write a short point-free implementation of this
function as a one-liner:
runBind :: IO ()
= getArgs >>= print . sum . map read runBind
This is a fairly idiomatic way to write the function, but if you find point-free
code hard to read, we can refactor this a bit to add some intermediate bindings
that might make it more readable. First, we can factor out the pure code that
transforms the list of strings we get from getArgs
into the sum that we want
to display:
runBind :: IO ()
= getArgs >>= print . sumInputs
runBind where
= sum $ map read inputs sumInputs inputs
We’re still composing print
with sumInputs
in this example. If you want to
go another step, we can add another binding for printing out the results of
summing the inputs:
runBind :: IO ()
= getArgs >>= showSum
runBind where
= sum $ map read inputs
sumInputs inputs = print $ sumInputs inputs showSum inputs
Alternatively, we can stop using (>>=)
and, instead use do
notation. Let’s
take a look at a similar implementation built around do
:
runDo :: IO ()
= do
runDo <- getArgs
arguments let sumOfArgs = sum $ map read arguments
print sumOfArgs
You’ll notice in this example that do
notation tends to encourage a somewhat
more explicit style of programming with more named bindings and less
composition. You can choose whichever style you prefer. For the next part of
this exercise, we’ll stick do
notation.
Building a version of our program that allows the user to select an operation isn’t much more difficult conceptually than supporting only addition, but the code we need to write will be more complicated due to additional error handling. Let’s take a look at the program, and then walk through how it works:
runCalculator :: IO ()
= do
runCalculator <- getArgs
args case args of
-> putStrLn argsError
[] -> putStrLn argsError
[_] :numStrs) ->
(opcase getOperation op of
Just f ->
let nums = map read numStrs
in print $ f nums
Nothing -> putStrLn $ opError op
where
=
argsError "Missing arg(s). Need an operator and at least 1 number"
=
opError op <> " - Unrecognized Operator. Please use one of +,*,-,/"
op =
getOperation op case op of
"+" -> Just sum
"*" -> Just product
"-" -> Just $ foldl1 (-)
"/" -> Just $ foldl1 div
-> Nothing _
In the earlier versions of our program, after getting the arguments we
immediately converted them to numbers, then added them up. Now, we need to deal
with a number of different error cases. The first thing we do in this version of
our program is to check the arguments to make sure that we have gotten at least
two arguments- one operator and at least one number. If we’ve gotten the right
number of arguments, then we need to check that the first argument is an
operator that we know how to handle. If it is, we return a function that applies
that operator to the remainder of our inputs. Otherwise, we return Nothing
.
You’ll notice that we’re using the foldl1
function for subtraction and
division. This is a useful helper function that behaves similarly to foldl
,
but it uses the first element of the list as the starting accumulator value.
Once we know that we’ve got a valid operator, the last step is to convert the
remainder of our inputs to numbers, and then apply our operator. Let’s load this
up in ghci
and see how it works:
-- Normal Operations
"+", "1", "2", "3"] runCalculator
λ withArgs [6
"-", "5", "1", "1"] runCalculator
λ withArgs [3
"*", "2", "3", "5"] runCalculator
λ withArgs [30
"/", "1024", "2", "2", "2"] runCalculator
λ withArgs [128
-- Insufficient Arguments
λ withArgs [] runCalculatorMissing arg(s). Need an operator and at least 1 number
"+"] runCalculator
λ withArgs [Missing arg(s). Need an operator and at least 1 number
-- Invalid operator
"^", "2", "3"] runCalculator
λ withArgs [^ - Unrecognized Operator. Please use one of +,*,-,/
Later on in the book, you’ll learn some ways to handle errors more effectively, and with less verbosity.
Building A Word Replacement Utility
Write an application that will accept three arguments on the command line:
path
: The path to a fileneedle
: A word to find in the input filereplacement
: A word to use as a replacement when printing the file
When a user runs your program, you should print out the contents of the file at
path
, but replace all occurrences of needle
with replacement
in the
output. To make things easier, assume that you can use the words
function and
don’t need to worry about handling multiple spaces or words that span lines.
Hints
Click to reveal
You can use the readFile
function to read the contents of a file:
readFile :: FilePath -> IO String
Remember that FilePath
is an alias for String
, so you can pass any String
value to readFile
.
Click to reveal
There are four functions in Prelude
that will be helpful as you work to
replace the words in a document:
-- Converts a String into a list of words by splitting along spaces.
words :: String -> [String]
-- Converts a list of words into a String by joining them with spaces
unwords :: [String] -> String
-- Works like words, but it splits on newlines
lines :: String -> [String]
-- Works like unwords, but it joins the list with newlines
unlines :: [String] -> String
Solution
Click to reveal
As you saw in the previous exercise, writing programs that use command line arguments and deal with files can introduce a lot of additional error handling that can detract from the core problem that we’re trying to solve. This time, let’s focus on solving our problem without the extra error the handing.
We’ll need to work with three different command line arguments for this program. Let’s start by creating a new record to hold our configuration data:
data Config = Config
configInputFile :: FilePath
{ configNeedle :: String
, configReplacement :: String
, }
Next, let’s create a new IO action that will get the command line arguments and
use them to generate a Config
record:
getConfig :: IO Config
= do
getConfig <- getArgs
[path, needle, replacement] return $ Config path needle replacement
Now that we have a config, let’s create another IO action to handle reading a
file and replacing the contents based on the current configuration. We’d like to
keep the pure code separate from the code with side effects, so we’ll call a
not-yet-written function named replaceTargetInDocument
that will do the actual
work of replacing the text. We’ll implement that function soon, for now we’ll
create a placeholder and leave it undefined
.
replaceTargetInDocument :: String -> String -> String -> String
= undefined
replaceTargetInDocument
runConfig :: Config -> IO String
Config path needle replacement) = do
runConfig (<- readFile path
document return $ replaceTargetInDocument needle replacement document
Although runConfig
does most of the heavy lifting, we’ll still need a main
function to get the config, pass it into runConfig
, and finally to print out
the results. Thanks to the way we’ve written these functions, we can
easily combine them with (>>)
:
main :: IO ()
= getConfig >>= runConfig >>= putStrLn main
We’re getting close to a solution, but we still haven’t can’t quite test
this. Since we haven’t defined replaceTargetInDocument
, any attempt to test
our program will crash:
"/tmp/poem.txt", "George", "Echo"] main
λ withArgs [*** Exception: Prelude.undefined
CallStack (from HasCallStack):
error, called at libraries/base/GHC/Err.hs:74:14 in base:GHC.Err
undefined, called at WordReplacement.hs:16:27 in solution-code-0-inplace:EffectiveHaskell.Exercises.Chapter7.WordReplacement
Let’s finish the last bit of our program and then try again. We need to define
replaceTargetInDocument
. This function will be responsible for replacing every
occurrence of the needle in a document with the replacement:
replaceTargetInDocument :: String -> String -> String -> String
=
replaceTargetInDocument needle replacement unwords . map replaceTargetWith . words
where
replaceTargetWith input| needle == input = replacement
| otherwise = input
This function works by first taking the full document and converting it to a
list of individual words using the words
function from Prelude
. Next, for
each individual word, we check to see if the word matches needle
and, if so,
replace it with replacement
. Finally, we re-combine all of the
post-replacement words into a single string with the unwords
function.
With this last function defined, we can test our new program. Let’s give it a try with a short poem:
Once was a parrot, George by name,
Who played a quite unusual game.
A fervent coder, to our surprise,
In love with Haskell’s neat disguise.
“Good day,” George squawks, takes his stance,
In lines of Haskell code, he’d dance.
From loops to functions, night and day,
In data types, George would play.
George wasn’t your typical bird,
His love for code, it was absurd.
“Skip the cracker, bring me scripts,
Watch my joy in coding flips!”
George, oh George, so bright and clever,
In the world of bugs, he’d never waver.
His playground wasn’t skies or trees,
But the logic of his machine’s keys.
“Give me Haskell,” cries George in glee,
His feathers twitching with pure spree.
The joy of coding he implores,
Syntax sugar, he adores.
So here’s to George, with his might,
Coding Haskell, day and night.
Remember him when you hear a squawk,
It’s George the Parrot, in code talk.
Let’s use our new program to try to replace George
with Echo
in the body of
our poem:
user@host:~WordReplacement$ ghc WordReplacement.hs
user@host:~WordReplacement$ ./WordReplacement ./poem.txt George Echo
Once was a parrot, Echo by name, Who played a quite unusual game. A fervent
coder, to our surprise, In love with Haskell's neat disguise. "Good day," Echo
squawks, takes his stance, In lines of Haskell code, he'd dance. From loops to
functions, night and day, In data types, Echo would play. Echo wasn't your
typical bird, His love for code, it was absurd. "Skip the cracker, bring me
scripts, Watch my joy in coding flips!" George, oh George, so bright and clever,
In the world of bugs, he'd never waver. His playground wasn't skies or trees,
But the logic of his machine's keys. "Give me Haskell," cries Echo in glee, His
feathers twitching with pure spree. The joy of coding he implores, Syntax sugar,
he adores. So here's to George, with his might, Coding Haskell, day and
night. Remember him when you hear a squawk, It's Echo the Parrot, in code talk.
Our program seems to be working pretty well, but our use of words
and
unwords
is causing us to lose newlines. Perfectly preserving formatting can
turn into a pretty complicated problem if we want to address all possible edge
cases, but let’s take one more pass at a slightly more robust implementation of
our program. In our new version, we’ll first split our program into lines, then
split each line into words. We’ll lose extra spacing between words, but we’ll
still be able to preserve newlines:
replaceTargetInDocument :: String -> String -> String -> String
=
replaceTargetInDocument needle replacement unlines . map replaceInLine . lines
where
= unwords . map replaceTargetWith . words
replaceInLine
replaceTargetWith input| needle == input = replacement
| otherwise = input
As you can see, we only need to make a couple of minor changes to
replaceTargetInDocument
to add support for retaining empty lines. Instead of
immediately breaking the entire document into words and calling
replaceTargetWith
, we first break our document into lines. We take the same
approach for each line that we originally took for the whole document: break the
line into words, apply replaceTargetWith
to each word, then rejoin the
docment. Let’s try it out:
user@host:~WordReplacement$ ghc WordReplacement.hs
user@host:~WordReplacement$ ./WordReplacement ./poem.txt George Echo
Once was a parrot, Echo by name,
Who played a quite unusual game.
A fervent coder, to our surprise,
In love with Haskell's neat disguise.
"Good day," Echo squawks, takes his stance,
In lines of Haskell code, he'd dance.
From loops to functions, night and day,
In data types, Echo would play.
Echo wasn't your typical bird,
His love for code, it was absurd.
"Skip the cracker, bring me scripts,
Watch my joy in coding flips!"
George, oh George, so bright and clever,
In the world of bugs, he'd never waver.
His playground wasn't skies or trees,
But the logic of his machine's keys.
"Give me Haskell," cries Echo in glee,
His feathers twitching with pure spree.
The joy of coding he implores,
Syntax sugar, he adores.
So here's to George, with his might,
Coding Haskell, day and night.
Remember him when you hear a squawk,
It's Echo the Parrot, in code talk.