Command Line Haskell and Error Handling Examples
I was working with using Haskell on the command line recently and it occurred to me that it might be interesting to demo it and walk through some error handling scenarios at the same time.
Like Perl, Python and Ruby you can invoke ghc on the command line to run programs on the fly. Yes, Haskell makes for a good scripting language.
Let’s say we have a column of numbers that we want to add up. You may have gotten these from a report you generated on your code repository or from anywhere. You can use ghc to sum up the numbers.
% cat /tmp/nums
9
3
4
% cat /tmp/nums | ghc -c 'interact ((++ "\n") . show . sum . map read . lines)'
16
The interact
function will read from stdin
and send output to stdout
without any work on our part. It just requires that your code can accept a
String
and produces a String
. We then break up the input into separate
lines, use read
to convert the String
to Num
s, add them up and turn the
result back into a String
.
We could just as well have put the code into a file as another way of using it in our command line pipeline.
% cat sum.hs
main = interact ((++ "\n") . show . sum . map read . lines)
% cat /tmp/nums | runhaskell sum.hs
16
Error Handling Style 1
Let’s say that our column of numbers didn’t always contain good data. Once in a while we’d get a bad dataset that didn’t have a number in it. How would our code react?
% cat /tmp/nums-error
9
3
not a num
4
% cat /tmp/nums-error | ghc -e 'interact ((++ "\n") . show . sum . map read . lines)'
*** Exception: Prelude.read: no parse
Though we don’t have any exception handling code in place, the read
function
chooses to throw an ErrorCall Exception
which will halt processing and print
out a somewhat related, but kind of generic message. This might be enough for us
if all we wanted was a quick hack to sum some numbers. But if this was to grow
into some long term code that might occasionally get bad data, the error
handling would need to be a bit better because right now we’re left wondering
what failed to parse.
Error Handling Style 2
Well now we’ll just move our code into a file since it will be much easier to
work with it that way. We’ll introduce another function, catch
, which will be
used to catch the ErrorCall Exception
raised by read
so that we can
interject a better error message. You’ll notice that we’re hiding the catch
function found in the Prelude
. That’s because it only catches IOErrors
as
you can see from the type signature, catch :: IO a -> (IOError -> IO a) -> IO a
. What we want is the catch
that’s in Control.Exception
since it will
catch what we want.
-- sum.hs
import Prelude hiding ( catch )
import Control.Exception
import System.IO
main = catch (interact ((++ "\n") . show . sum . map read . lines))
(\e -> hPutStrLn stderr ("couldn't sum lines: " ++ show e))
% cat /tmp/nums-error | runhaskell sum.hs
couldn't sum lines: Prelude.read: no parse
Well this worked a little better than before. We caught the exception ourselves and added a message. But we could still use a bit more information. If we had a large dataset and this code bombed on something in the middle of it, we’d be left wondering what failed to parse and how could we debug this. If we want better error reporting, we’re going to have to add more code.
Error Handling Style 3
Style 2 was a big improvement over style 1 in that we we’re explicitly catching the exception that was thrown. The big problem though, was that we didn’t have enough context information … we didn’t have the line from the dataset that was bad.
main = do
m <- hGetContents stdin
nums <- mapM readM . lines $ m
print (sum nums)
`catch` (\e -> hPutStrLn stderr ("couldn't sum lines: " ++ show e))
readM :: (Monad m, Read a) => String -> m a
readM s | [x] <- parse = return x
| otherwise = fail $ "Failed to parse \"" ++ s ++ "\" as a number."
where
parse = [x | (x,_) <- reads s]
While the code may look considerably different than before, it’s still doing the
same simple steps. The main difference is that instead of using Prelude.read
,
we’ve written a custom function readM
that will run in any Monad
. If it is
unable to convert a String
into the Num
type that we expect, it will get a
pattern match failure on [x] <- parse
and will instead match up against the
otherwise
guard. From there we signal a fail
with our more descriptive
error.
The main
function has changed a bit to handle our new monadic read function.
Instead of interact
we’re manually reading from stdin
. But notice that our
exception handler is the same as before. It didn’t need to change. So how well
does this version work?
% cat /tmp/nums-error | runhaskell sum.hs
couldn't sum lines: user error (Failed to parse "not a num" as a number.)
Well there we go. Now we know what to search for in our dataset if we encounter a problem.
Summary
One can use Haskell as a scripting language similar to others. Furthermore, language features which are hard wired into other languages such as exception handling, are actually implemented as libraries in Haskell. This ability to extend the language via libraries both makes it very powerful for experts, but a bit overwhelming for newbies who are confused by the myriad ways to do things. The examples presented hopefully give some simple starting steps that people can build on to learn more.
Code
-- sum.hs
{-# LANGUAGE PatternGuards #-}
import Prelude hiding ( catch )
import Control.Exception
import Control.Monad ( liftM )
import System.IO
main = style1
style1 = interact ((++ "\n") . show . sum . map read . lines)
style2 = catch (interact ((++ "\n") . show . sum . map read . lines))
(\e -> hPutStrLn stderr ("couldn't sum lines: " ++ show e))
style3 = do
m <- hGetContents stdin
nums <- mapM readM . lines $ m
print (sum nums)
`catch` (\e -> hPutStrLn stderr ("couldn't sum lines: " ++ show e))
readM :: (Monad m, Read a) => String -> m a
readM s | [x] <- parse = return x
| otherwise = fail $ "Failed to parse \"" ++ s ++ "\" as a number."
where
parse = [x | (x,_) <- reads s]