7
\$\begingroup\$

Here's a program I wrote to convert numbers into English words using Haskell.

import Data.Char
import Data.List

type WordNum = String

ones :: (Integral a, Show a) => a -> WordNum
ones 1 = "one"
ones 2 = "two"
ones 3 = "three"
ones 4 = "four"
ones 5 = "five"
ones 6 = "six"
ones 7 = "seven"
ones 8 = "eight"
ones 9 = "nine"
ones n = error (show n ++ " is not a one-digit value")

teens :: (Integral a, Show a) => a -> WordNum
teens 10 = "ten"
teens 11 = "eleven"
teens 12 = "twelve"
teens 13 = "thirteen"
teens 14 = "fourteen"
teens 15 = "fifteen"
teens 16 = "sixteen"
teens 17 = "seventeen"
teens 18 = "eighteen"
teens 19 = "nineteen"
teens n  = error (show n ++ " is not a teen")

tens :: (Integral a, Show a) => a -> WordNum
tens 1 = "ten"
tens 2 = "twenty"
tens 3 = "thirty"
tens 4 = "forty"
tens 5 = "fifty"
tens 6 = "sixty"
tens 7 = "seventy"
tens 8 = "eighty"
tens 9 = "ninety"
tens n  = error (show n ++ " is not a tens place value")

groups :: [WordNum]
groups = ["", " thousand", " million", " billion", " trillion"]


groupToWord :: (Integral a, Show a) => a -> String
groupToWord n
    | n == 0    = ""
    | n < 10    = ones n
    | n < 20    = teens n
    | n < 100   = tens (n `div` 10) ++ ' ' : (groupToWord $ n `mod` 10)
    | n < 1000  = ones (n `div` 100) ++ " hundred " ++ (groupToWord $ n `mod` 100)
    | otherwise = error (show n ++ " is not a 3-digit group")

-- Splits a number into groups in reverse order
splitNum :: (Integral a, Show a) => a -> [a]
splitNum n
    | n <= 999  = [n]
    | otherwise = (n `mod` 1000) : splitNum (n `div` 1000)

numToWord :: (Integral a, Show a) => a -> String
numToWord n
    | n == 0     = "zero"
    | n >= 10^15 = error "Doesn't support numbers bigger than trillions"
    | otherwise  = concat $ intersperse ", " [w ++ g | (w,g) <- reverse (zip wordGroups groups)]
    where
        wordGroups = toWordGroups $ splitNum n

        toWordGroups :: (Integral a, Show a) => [a] -> [WordNum]
        toWordGroups (g:gs) = groupToWord g : toWordGroups gs
        toWordGroups _ = []

It seems like there's a lot of redundancy, and I'm also not very happy with how I had to join the lists. Also, is there a way to do more consing and less appending? I was hoping to cons the groups back together after they were made words so I wouldn't have to reverse the zipped list afterward. Also, as always, I'd really appreciate general comments on improvements to style and best practice.

\$\endgroup\$

1 Answer 1

5
\$\begingroup\$

Pattern matching or list element selection?

It's possible to reduce the size of ones, tens, teens and so on if we use !! on a list instead of pattern matching. That's a completely other style though:

ones :: (Integral a, Ord a) => a -> WordNum
ones n 
  | n > 0 && n < 10 = onsies !! fromIntegral n
  | otherwise       = error $ "ones: not a one-digit value"
  where
    onsies = words "one two three four five six seven eight nine"

Note that I removed the Show a constraint, because it's not necessary if our value n is in our range. It's only necessary in the error case. Instead, we should add where the error happened. Newer variants of GHC/base include a call-stack, therefore we will know the malfunctioning line, but it's always nice to know at least the function name. It also follows the base-style error messages, e.g.

head (x:_) = x
head []    = error "Prelude.head: empty list"

Either way, back to the code. Several functions are partial. Usually, you want your exported functions to be total, e.g. they never return _|_ (an infinite loop, an error, undefined,...). So instead of the ones above, we could write

ones :: (Integral a, Ord a) => a -> Maybe WordNum
ones n 
  | n > 0 && n < 10 = Just $ onsies !! fromIntegral n
  | otherwise       = Nothing
  where
    onsies = words "one two three four five six seven eight nine"

However you wouldn't export all your helper functions either way, so lets keep the error variant. By the way, I've used words "one..." since my keyboard is slightly broken, but it's the same as ["one","two","three",...].

divMod, map and other standard library functions

Sometimes re-inventing the wheel is fun, but usually we want to try to get the most out of our standard library functions.

divMod

There are several occasions where we use both d = a `div` b and m = a `mod` b. If we use both, we can use (d,m) = a `divMod` b. That prevents typos like

    | n < 100   = tens (n `div` 10) ++ ' ' : (groupToWord $ n `mod` 11)
                                                    -- whoops        ^

So let us use it in splitNum:

splitNum :: Integral a => a -> [a]
splitNum n
    | d == 0    = [n]
    | otherwise = m : splitNum d
  where
    (d,m) = n `divMod` 1000

Note that quotRem will return the same values as divMod on positive numbers and is slightly faster.

zipWith and map

You use zip in your list comprehension just to ++ the zipped elements together. We can achieve this in a single step with zipWith:

[w ++ g | (w,g) <- reverse (zip wordGroups groups)]
= [ w ++ g | (w,g) <- reverse (zipWith (,)  wordGroups groups)]
= [ x      | x     <- reverse (zipWith (++) wordGroups groups)]
= reverse $ zipWith (++) wordGroups groups

toWordGroups is map groupToWord. If we use both functions in numToWord, we end up with

numToWord :: (Integral a, Ord a) => a -> String
numToWord n
    | n == 0     = "zero"
    | n >= 10^15 = error "numToWord: Doesn't support numbers bigger than trillions"
    | otherwise  = concat $ intersperse ", " $ reverse $ zipWith (++) wordGroups groups
    where
        wordGroups = map groupToWord $ splitNum n
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.