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.

asked Nov 2, 2017 at 1:38
\$\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
answered Nov 2, 2017 at 8:03
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.