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 Nums, 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]

tags: haskell

Comments

comments powered by Disqus