Understanding IO

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)
doubleIO = return $ return "hello"

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)
returnRead = return $ readFile "/tmp/example"

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 ()
printNestedIO = (>>= (>>= putStrLn))

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 ()
printNestedIO nestedIO = nestedIO >>= (>>= putStrLn)

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 ()
printNestedIO nestedIO = nestedIO >>= go
  where
    go :: IO String -> IO ()
    go ioString = ioString >>= putStrLn

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:

printNestedIO nestedIO = nestedIO >>= go

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
m a :: IO (IO String)
b :: ()
m b :: IO ()

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
m a :: IO String
b :: ()
m b :: IO ()

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
joinIO ioAction = undefined

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
joinIO ioAction = ioAction >>= _

If we run this, the compiler will give us some useful information:

src/EffectiveHaskell/Exercises/Chapter7/Join.hs:4:32: error:
Found hole: _ :: IO a -> IO a
      Where: ‘a’ is a rigid type variable bound by
               the type signature for:
                 joinIO :: forall a. IO (IO a) -> IO a
               at /home/rebecca/projects/effective-haskell.com/solution-code/src/EffectiveHaskell/Exercises/Chapter7/Join.hs:3:1-27
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)
          (bound at /home/rebecca/projects/effective-haskell.com/solution-code/src/EffectiveHaskell/Exercises/Chapter7/Join.hs:4:8)
        joinIO :: IO (IO a) -> IO a
          (bound at /home/rebecca/projects/effective-haskell.com/solution-code/src/EffectiveHaskell/Exercises/Chapter7/Join.hs:4:1)
      Valid hole fits include
        id :: forall a. a -> a
          with id @(IO a)
          (imported from ‘Prelude’ at /home/rebecca/projects/effective-haskell.com/solution-code/src/EffectiveHaskell/Exercises/Chapter7/Join.hs:1:8-47
           (and originally defined inGHC.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
joinIO ioAction = ioAction >>= returnInnerIO
  where
    returnInnerIO :: IO a -> IO a
    returnInnerIO a = a >>= return a

This works just as you’d expect, and we can test it out in ghci:

λ :t joinIO (return $ return "hello")
joinIO (return $ return "hello") :: IO String

λ joinIO (return $ return "hello") >>= putStrLn
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
joinIO ioAction = ioAction >>= returnInnerIO
  where
    returnInnerIO :: IO a -> IO a
    returnInnerIO a = 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
joinIO ioAction = ioAction >>= id

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)
λ join (return $ return "hello") >>= putStrLn
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]
sequenceIO [] = return []
sequenceIO (x:xs) = undefined

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]
sequenceIO [] = return []
sequenceIO (x:xs) = x >>= \x' -> undefined

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]
sequenceIO [] = return []
sequenceIO (x:xs) = x >>= \x' -> return [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]
sequenceIO [] = return []
sequenceIO (x:xs) = x >>= \x' -> return [x']
  where 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]
sequenceIO [] = return []
sequenceIO (x:xs) = rest >>= \rest' -> x >>= \x' -> return $ x' : rest'
  where 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.

λ sequenceIO $ map print [1..10]
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:

λ printUpToTen = map print [1..10]
λ :t printUpToTen
printUpToTen :: [IO ()]
λ :t sequenceIO printUpToTen
sequenceIO printUpToTen :: IO [()]
λ sequenceIO printUpToTen
1
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 = sequenceIO actions >> return ()

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:

λ sequenceIO_ $ map print [1..10]
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:

a >>= \b -> doSomethingWith b

We can rewrite it with do notation like this:

do
  b <- a
  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]
sequenceIO [] = return []
sequenceIO (x:xs) = do
  x' <- x
  xs' <- sequenceIO 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 ()
printArgs = getArgs >>= print

We can load this up in ghci and test it with withArgs by passing in a list of the arguments that getArgs should return:

λ withArgs ["hello", " ", "world"] printArgs
["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:

(first:rest) <- getArgs

Let’s look at an example where we print out the first argument, then the remaining arguments:

printArgs :: IO ()
printArgs = do
  (first:rest) <- getArgs
  putStrLn $ "first: " <> first
  putStrLn $ "rest: " <> show rest

This works as long as we pass in at least one argument:

λ withArgs ["first"] printArgs
first: first
rest: []

λ withArgs ["first", "second", "third"] printArgs
first: first
rest: ["second","third"]

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:

  1. get the command line arguments
  2. conver them to numbers
  3. add the list of numbers
  4. 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 ()
runBind = getArgs >>= print . sum . map read

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 ()
runBind = getArgs >>= print . sumInputs
  where
    sumInputs inputs = sum $ map read 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 ()
runBind = getArgs >>= showSum
  where
    sumInputs inputs = sum $ map read inputs
    showSum inputs = print $ sumInputs 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 ()
runDo = do
  arguments <- getArgs
  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 ()
runCalculator = do
  args <- getArgs
  case args of
    [] -> putStrLn argsError
    [_] -> putStrLn argsError
    (op:numStrs) ->
      case 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 =
      op <> " - Unrecognized Operator. Please use one of +,*,-,/"
    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
λ withArgs ["+", "1", "2", "3"] runCalculator
6
λ withArgs ["-", "5", "1", "1"] runCalculator
3
λ withArgs ["*", "2", "3", "5"] runCalculator
30
λ withArgs ["/", "1024", "2", "2", "2"] runCalculator
128

-- Insufficient Arguments
λ withArgs [] runCalculator
Missing arg(s). Need an operator and at least 1 number
λ withArgs ["+"] runCalculator
Missing arg(s). Need an operator and at least 1 number

-- Invalid operator
λ withArgs ["^", "2", "3"] runCalculator
^ - 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 file
  • needle: A word to find in the input file
  • replacement: 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
getConfig = do
  [path, needle, replacement] <- getArgs
  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
replaceTargetInDocument = undefined

runConfig :: Config -> IO String
runConfig (Config path needle replacement) = do
  document <- readFile path
  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 ()
main = getConfig >>= runConfig >>= putStrLn

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:

λ withArgs ["/tmp/poem.txt", "George", "Echo"] main
*** 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
    replaceInLine = unwords . map replaceTargetWith . words
    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.