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!