Getting a Handle on IO

Welcome to May! This month is "All About IO". We'll be discussing many of the different useful types and functions related to our program's input and output. Many of these will live in the System.IO library module, so bookmark that if you want to demystify how IO works in Haskell!

The first concept you should get a grasp on if you want to do anything non-trivial with IO in Haskell is the idea of a Handle. You can think of a handle as a pointer to a file. We can use this pointer to read from the file or write to the file. The first interaction you'll have with a handle is when you generate it with openFile.

data Handle

openFile :: FilePath -> IOMode -> IO Handle

The first argument here is the FilePath, which is just a type alias for a plain old string. The second argument tells us how we are interacting with the file. There are four different modes of interacting with a file:

data IOMode =
  ReadMode |
  WriteMode |
  AppendMode |
  ReadWriteMode

Each one allows a different set of IO operations, and these are mostly intuitive. With ReadMode, we can read lines from the handle we receive, but we can't edit the file. With AppendMode, we can write new lines to the end of the file, but we can't read from it. In order to do both kinds of operations, we need ReadWriteMode.

As an important note, WriteMode is the most dangerous! This mode only allows writing. It is impossible to read from the file handle. This is because opening a file in WriteMode will erase its existing contents. At first glance it's easy to think that WriteMode will allow you to just write to the end of the file, adding to its contents. But this is the job of AppendMode! Note however that both these modes will create the file if it does not already exist.

Here's an example of some simple interactions with files:

readFirstLine :: FilePath -> IO String
readFirstLine fp = do
  handle <- openFile fp ReadMode
  let firstLine = hGetLine handle
  hClose handle
  return firstLine

writeSingleLine :: FilePath -> String -> IO ()
writeSingleLine fp newLine = do
  -- Create file if it doesn't exist, overwrite its contents if it does!
  handle <- openFile fp WriteMode
  hPutStrLn handle newLine
  hClose handle

addNewLine :: FilePath -> String -> IO ()
addNewLine fp newLine = do
  handle <- openFile fp AppendMode
  hPutStrLn handle newLine
  hClose handle

A few notes. All these functions use hClose when they're done with the handle. This is an important way of letting the file system know we are done with this file.

hClose :: Handle -> IO ()

If we don't close our handles, we might end up with conflicts. Multiple different handles can exist at the same time for reading a file. But only a single handle can existing for writing to a file at any given time. And if we have a write-capable handle (anything other than ReadMode), we can't have other ReadMode handles to that file. So if we write a file but don't close it's handle, we won't be able to read from that file later!

A couple of the functions we wrote above might sound familiar to the most basic IO functions. The first functions you learned in Haskell were probably print, putStrLn, and getLine:

print :: (Show a) => a -> IO ()

putStrLn :: String -> IO ()

getLine :: IO String

The first two will output text to the console, the third will pause and let the user enter a line on the console. Above, we used these two functions:

hPutStrLn :: Handle -> String -> IO ()

hGetLine :: Handle -> IO String

These functions work exactly the same, except they are dealing with a file, so they have the extra Handle argument.

The neat thing is that interacting with the console uses the same Handle abstraction! When your Haskell program starts, you already have access to the following open file handles:

stdin :: Handle

stdout :: Handle

stderr :: Handle

So the basic functions are simply defined in terms of the Handle functions like so:

putStrLn = hPutStrLn stdout

getLine = hGetLine stdin

This fact allows you to write a program that can work either with predefined files as the input and output channels, or the standard handles. This is amazingly useful for writing unit tests.

echoProgram :: (Handle, Handle) -> IO ()
echoProgram (inHandle, outHandle) = do
  inputLine <- hGetLine inHandle
  hPutStrLn outHandle inputLine

main :: IO ()
main = echoProgram (stdin, stdout)

testMain :: IO ()
testMain = do
  input <- openFile "test_input.txt" ReadMode
  output <- openFile "test_output.txt" WriteMode
  echoProgram (input, output)
  hClose input
  hClose output
  -- Assert that "test_output.txt" contains the expected line.
  ...

That's all for our first step into the world of IO. For the rest of this month, we'll be looking at other useful functions. For now, make sure you subscribe to our monthly newsletter so you get a summary of anything you might have missed. You'll also get access to our subscriber resources, which can really help you kickstart your Haskell journey!

Previous
Previous

Handling Files more Easily

Next
Next

Traverse: Fully Generalized Loops