1
\$\begingroup\$

As an exercise, I decided to write a program that spells out numbers.

For example:

(stringify-int 12345)
=> "twelve thousand three hundred forty-five"

It ended up both being simpler and more difficult than I thought. Once I realized that you can break the number down into 3-digit chunks, then just add the appropriate suffixes to each chunk, it was pretty straightforward. The number of corner cases all over the place made some things difficult. I got more than half of it written before I realized that I had no idea how I was going to handle 10. I ended up shimming it into an existing mechanism.

The general algorithm:

  • Break the number into 3-digit chunks, padded with 0s
  • Use the values in the hundreds, tens and one place of each chunk to index vectors containing the names of the place
  • Attach the appropriate place suffixes to each chunk
  • Join the chunks together

I'd like general input on anything here. I'd especially like suggestions on how to handle 10 better.

General helpers used in core and tests:

(ns number-to.words.helpers)

(defn div-mod [numer denom]
  ((juxt quot mod) numer denom))

(defn parse-int [n]
  (try
    (Long/parseLong n)

    (catch NumberFormatException _
      nil)))

(defn str-num
  "Returns a string representing a number indicated by scientific notation-ish arguments.
  (str-num 4 5) => \"400000\""
  [tens-mult n-zeros]
  (str (str tens-mult) (apply str (repeat n-zeros "0"))))

Tests:

(ns number-to.words.try1-test
  (:require [clojure.test :refer :all]

            [number-to.words.try1 :refer :all]
            [number-to.words.helpers :as h]))

; https://www.grammarbook.com/numbers/numbers.asp
; "Rule 2a. Hyphenate all compound numbers from twenty-one through ninety-nine."

(defmacro are-correct [f & pairs]
  `(are [n# w#] (= (~f n#) w#) ~@pairs))

(deftest stringify-chunk-test
  (testing "arbitary numbers"
    (are-correct stringify-chunk
                 4 "four"
                 12 "twelve"
                 15 "fifteen"
                 25 "twenty-five"
                 36 "thirty-six"
                 50 "fifty"
                 69 "sixty-nine"
                 88 "eighty-eight"
                 95 "ninety-five"
                 125 "one hundred twenty-five"
                 174 "one hundred seventy-four"
                 468 "four hundred sixty-eight"
                 750 "seven hundred fifty"
                 987 "nine hundred eighty-seven")))

(deftest stringify-test
  (testing "even base names are correct"
    (are-correct stringify-int
                 1 "one"
                 10 "ten"
                 1e2 "one hundred"
                 1e3 "one thousand"
                 1e4 "ten thousand"
                 1e5 "one hundred thousand"
                 1e6 "one million"
                 1e7 "ten million"
                 1e8 "one hundred million"
                 1e9 "one billion"
                 1e12 "one trillion"
                 1e15 "one quadrillion"
                 1e18 "one quintillion"

                 (bigint (h/str-num 5 24))
                 "five septillion"

                 (+ (bigint (h/str-num 2 37)) 99306)
                 "twenty undecillion ninety-nine thousand three hundred six"

                 (+ (bigint (h/str-num 7 41)) 36)
                 "seven hundred duodecillion thirty-six"))

  (testing "arbitary numbers"
    (are-correct stringify-int
                 2 "two"
                 11 "eleven"
                 16 "sixteen"
                 23 "twenty-three"
                 42 "forty-two"
                 99 "ninety-nine"
                 123 "one hundred twenty-three"
                 1234 "one thousand two hundred thirty-four"
                 5000 "five thousand"
                 9999 "nine thousand nine hundred ninety-nine"
                 54321 "fifty-four thousand three hundred twenty-one"

                 (+ (long 2e15) 12345)
                 "two quadrillion twelve thousand three hundred forty-five"

                 (+ (long 5e18) 5000 23)
                 "five quintillion five thousand twenty-three")))

Code:

(ns number-to.words.try1
  (:require [criterium.core :as cc]
            [number-to.words.helpers :as h]
            [clojure.string :as s]))

(def digit-names ["one" "two" "three" "four" "five" "six" "seven" "eight" "nine"])

(def digit-prefixes ["thir" "four" "fif" "six" "seven" "eigh" "nine"])

(def teen-names (->> digit-prefixes
                     (mapv #(str % "teen"))
                     (into ["eleven" "twelve"])))

(def tens-names (as-> digit-prefixes d
                      (assoc d 1 "for") ; forty drops the "u" for some reason
                      (into ["twen"] d)
                      (mapv #(str % "ty") d)))

; Can only get to quintillion anyways before we exceed what a long can hold.
; TODO: Adapt to allow big nums, or remove excess place-names
(def place-names ["" "thousand" "million" "billion" "trillion" "quadrillion" "quintillion"
                  "sextillion" "septillion" "octillion" "nonillion" "decillion" "undecillion"
                  "duodecillion"])

(def places-chunk-size 3)

(def max-represntable-number (bigint (apply str (repeat 41 9))))

(defn- reverse-chunk-n
  "Chunks the number into chunk-size many places. Returns the chunks in reverse order.
  (reverse-chunk-n 1234 3) => [234 1]"
  [n chunk-size]
  (->> n
       (str)
       (reverse)
       (partition chunk-size chunk-size (repeat 0))
       (mapv #(->> % (reverse) (apply str) h/parse-int))))

(defn- stringify-ones [digit]
  {:pre (<= 1 digit 9)}

  (if (zero? digit)
    ""
    (digit-names (dec digit))))

(defn- stringify-teen [teen-n]
  {:pre [(<= 11 teen-n 19)]}

  (teen-names (- teen-n 11)))

(defn- stringify-tens-place [tens-place]
  {:pre [(or (<= 2 tens-place 9) (zero? tens-place))]}

  (if (zero? tens-place)
    ""
    (tens-names (- tens-place 2))))

(defn- stringify-mid-less-than-hundred [chunk]
  {:pre [(<= 20 chunk 99)]}

  (let [[tens ones] (h/div-mod chunk 10)]
    (str (stringify-tens-place tens)

         (if (or (zero? tens) (zero? ones))
           ""
           "-")

         (stringify-ones ones))))

(declare stringify-chunk)
(defn- stringify-greater-hundred [chunk]
  {:pre [(<= 100 chunk 999)]}

  (let [[hundreds tens-and-ones] (h/div-mod chunk 100)]
    (str (stringify-ones hundreds) " hundred"

         (if (zero? tens-and-ones)
           ""
           (str " "
                (stringify-chunk tens-and-ones))))))

(defn stringify-chunk [chunk]
  {:pre [(<= 0 chunk 999)]}

  (let [f (condp > chunk
            10 stringify-ones
            11 (constantly "ten") ; TODO: Eww
            20 stringify-teen
            100 stringify-mid-less-than-hundred
            stringify-greater-hundred)]

    (f chunk)))

(defn stringify-int [n]
  {:pre [(<= 0 n max-represntable-number)]}

  (let [std-n (if (float? n)
                (long n)
                n)]

    (if (zero? std-n)
      "zero"

      (->> (reverse-chunk-n std-n places-chunk-size)
           (mapv vector place-names)
           (mapv (fn [[s n]] (if (zero? n)
                               ""
                               (let [sep (if (empty? s) "" " ")]
                                 (str (stringify-chunk n) sep s)))))
           (remove empty?) ; 0s stringify to empty strings, and need to be removed
           (reverse)
           (s/join " ")))))
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.