A few weeks ago, I finished a number to word converter and received an excellent review. It caused me to make several fundamental changes to numToWord
, such as using a list instead of a pattern matched function, using quotRem
, and removing Show
constraints. I also tried to use zipWith
, but I wasn't sure how to make it work correctly when filtering before uncurrying. However, I'm more interested in improving my newer wordToNum
converter. I played a little bit with trying to use Maybe
and Nothing
instead of defaulting to 0 everywhere, but it got very ugly very fast. Would there be a clean way of using Maybe throughout the module, and would it be better than defaulting to 0? Also, is there a cleaner way of splitting groups and blocks?
module MathSolver.NLP.WordNum (numToWord, wordToNum) where
import Data.Bool (bool)
import Data.Char
import Data.List.Split.Internals
import Data.Maybe
ones :: Integral a => [(String, a)]
ones = [("one",1), ("two",2), ("three",3), ("four",4), ("five",5), ("six",6), ("seven",7),
("eight",8), ("nine",9)]
teens :: Integral a => [(String, a)]
teens = [("ten",10), ("eleven",11), ("twelve",12), ("thirteen",13), ("fourteen",14),
("fifteen",15), ("sixteen",16), ("seventeen",17), ("eighteen",18), ("nineteen",19)]
tens :: Integral a => [(String, a)]
tens = [("ten",10), ("twenty",20), ("thirty",30), ("forty",40), ("fifty",50), ("sixty",60),
("seventy",70), ("eighty",80), ("ninety",90)]
groups :: [String]
groups = ["", " thousand", " million", " billion", " trillion", " quadrillion", " quintillion",
" sextillion", " septillion", " octillion", " nonillion", " decillion", " undecillion",
" duodecillion", " tredecillion", " quattuordecillion", " quindecillion", " sexdecillion",
" septendecillion", " octodecillion", " novemdecillion", " vigintillion"]
-- Maps a word to its power of 10 to calculate a group multiplier
groupList :: Integral a => [(String, a)]
groupList = [("thousand",3), ("million",6), ("billion",9), ("trillion",12), ("quadrillion",15),
("quintillion",18), ("sextillion",21), ("septillion",24), ("octillion",27),
("nonillion",30), ("decillion",33), ("undecillion",36), ("duodecillion",39),
("tredecillion",42), ("quattuordecillion",45), ("quindecillion",48),
("sexdecillion",51), ("septendecillion",54), ("octodecillion",57),
("novemdecillion",60), ("vigintillion",63)]
{--------------------------------------------------------------------------------------------------}
{--- NUMBER TO WORD CONVERSION ---}
{--------------------------------------------------------------------------------------------------}
-- Converts an Int or Integer to its String representation
numToWord :: Integral a => a -> String
numToWord n
| n < 0 = "negative " ++ numToWord (-n)
| n == 0 = "zero"
| n >= 10^65 = error "numToWord: Doesn't support numbers bigger than vigintillions! (10^65-1)"
| isHundred = groupToWord d100 ++ " hundred"
| otherwise = unwords $ reverse buildGroups
where
buildGroups = map (uncurry (++)) (filter (\(block,_) -> block /= "") (zip wordGroups groups))
wordGroups = map groupToWord $ splitNum n
isHundred = n <= 9000 && r1000 /= 0 && r100 == 0
r1000 = n `rem` 1000
(d100,r100) = n `quotRem` 100
-- Splits a number into groups in reverse order
splitNum :: Integral a => a -> [a]
splitNum n
| d == 0 = [n]
| otherwise = m : splitNum d
where
(d,m) = n `quotRem` 1000
-- Converts a group of digits to a number.
-- A group is usually 1 to 3 digits, but also includes numbers that are multiples of 100, but not
-- 1000, such as 1900. Words are not comma-separated as per the Chicago Manual of Style. This
-- prevents numbers from potentially seeming like a list of smaller numbers.
groupToWord :: Integral a => a -> String
groupToWord n = unwords $ numWords n
where
numWords :: Integral a => a -> [String]
numWords r
| r < 0 = "negative" : numWords (-r)
| r == 0 = []
| r < 10 = [toWord r ones]
| r < 20 = [toWord r teens]
| r < 100 && r10 /= 0 = [toWord n10 tens ++ "-" ++ toWord r10 ones]
| r < 100 = toWord n10 tens : numWords r10
| r < 1000 = toWord d100 ones : "hundred" : numWords r100
| otherwise = error "groupToWord: not a 3-digit or hundred group"
where
(n10, r10) = (r - r10, r `rem` 10)
(d100, r100) = r `quotRem` 100
-- Converts a number to a word given a number translation map
toWord :: Integral a => a -> [(String, a)] -> String
toWord n table = fromMaybe "" (lookup n (map swap table))
where
swap :: Integral a => (String, a) -> (a, String)
swap (x,y) = (y,x)
{--------------------------------------------------------------------------------------------------}
{--- WORD TO NUMBER CONVERSION ---}
{--------------------------------------------------------------------------------------------------}
-- Converts a single block to an Int or Integer
wordToNum :: Integral a => String -> a
wordToNum s
| s == "" = 0
| isNegative = (-1) * wordToNum (unwords $ tail (words s))
| otherwise = fromGroups $ splitGroups s
where
isNegative = head (words s) `elem` ["negative", "minus"]
-- Splits a full number into blocks on group words, keeping delimeters at the end of each group.
splitGroups :: String -> [String]
splitGroups str = map unwords $ split (dropBlanks $ oneOf groupWords) (words str)
where
-- Removes initial space in each entry of `groups` and ignores initial ""
groupWords = map tail (tail groups)
-- Calculates the total sum of all groups' values
fromGroups :: Integral a => [String] -> a
fromGroups (block:group:bs) = fromBlock (splitBlock block) * (multiplier group) + fromGroups bs
fromGroups [block] = fromBlock (splitBlock block)
fromGroups [] = 0
-- Calculates a group's multiplier, or defaults to 1 (10^0) if the group isn't valid
multiplier :: Integral a => String -> a
multiplier g = 10 ^ fromMaybe 0 (lookup g groupList)
-- Splits a block on "hundred", keeping the delimeter, and cleaning spacing and punctuation
splitBlock :: String -> [String]
splitBlock block = map clean $ split (onSublist "hundred") block
where
-- Switches all hyphens to spaces and removes all other non-letters
clean :: String -> String
clean w = [bool ' ' c (c /= '-') | c <- (unwords.words) w, isLetter c || c == '-' || c == ' ']
-- Calculates a block's value, defaulting to 0 if there's invalid input
fromBlock :: Integral a => [String] -> a
fromBlock [x,"hundred",y] = fromBlock (words x) * 100 + fromBlock (words y)
fromBlock [x,"hundred"] = fromBlock (words x) * 100
fromBlock [x,y]
| mapMember x (tail tens) && mapMember y ones = fromWord x tens + fromWord y ones
| otherwise = 0
fromBlock [w]
| length (words w) > 1 = fromBlock (words w)
| mapMember w ones = fromWord w ones
| mapMember w teens = fromWord w teens
| mapMember w tens = fromWord w tens
| otherwise = 0
fromBlock _ = 0
-- Converts a word to its numeric value from a given conversion table
fromWord :: Integral a => String -> [(String, a)] -> a
fromWord word table = fromMaybe 0 (lookup word table)
-- Determines whether a word is a member of a conversion table
mapMember :: Integral a => String -> [(String, a)] -> Bool
mapMember w table = isJust (lookup w table)
Edit: For groupWords
in splitGroups
, would it be better to use map (\(group,_) -> group) groupList
? Similarly, would it be better to replace groups
entirely with "" : map (\(group,_) -> " " ++ group) groupList
?