10
\$\begingroup\$

I'm kind of new to programming and I would need a suggestion whether there is a better way to solve this exercise.

I need to write a function dict_of_numbers() that returns a dictionary with numbers from 0 to 99 as key, and the numbers spelled out as value for the relative key.

This is the solution I wrote:

def dict_of_numbers():

    num_to_words_first_numbers = {0:"zero", 1:"one", 2:"two", 3:"three", 4:"four", 5:"five", 6:"six", 7:"seven", 8:"eight", 9:"nine", 10:"ten", 11:"eleven", 12:"twelve",
                             13:"thirteen",15:"fifteen", 18:"eighteen"}
    ten_by_ten = {20:"twenty", 30:"thirty", 40:"forty", 50:"fifty", 60:"sixty", 70:"seventy", 80:"eighty", 90:"ninety"}

    spelled_numbers = {}

    for number in range(0, 100):
        if number in num_to_words_first_numbers:
            spelled_numbers[number] = num_to_words_first_numbers[number]
        elif number in ten_by_ten:
            spelled_numbers[number] = ten_by_ten[number]
        else:
            number_as_string = str(number)
            char_list = []

            for char in number_as_string:
                char_list.append(int(char))

            first_part = char_list[0] * 10
            second_part = char_list[1]
            if first_part == 10:
                spelled_numbers[number]= str(num_to_words_first_numbers[second_part])+"teen"
            else:
                spelled_numbers[number]=str(ten_by_ten[first_part])+"-"+str(num_to_words_first_numbers[second_part])

    return spelled_numbers

It works fine but I would like to know whether there is a better solution and why it would be better.

\$\endgroup\$
1
  • 1
    \$\begingroup\$ Text to number would be much more fun! \$\endgroup\$
    – Stef
    Commented Nov 29, 2023 at 9:18

4 Answers 4

9
\$\begingroup\$

one dict or three

Using

  • num_to_words_first_numbers,
  • ten_by_ten, and
  • spelled_numbers

was an interesting design choice. It is certainly fine to leave it as is. But another approach would pre-populate spelled_numbers = num_to_words_first_numbers | ten_by_ten, or would just use the literals. That would simplify these tests:

        if number in num_to_words_first_numbers:
            ...
        elif number in ten_by_ten:
            ...

For a number that is already present (due to our initialization), we could simply continue.

That would offer the additional benefit of bringing the else clause four spaces closer to the left margin. This code is not yet deeply indented. But after nesting a few levels, we start to look for Extract Helper function opportunities, which let us reset back closer to the margin.


use anonymous intermediate expression

This is a well chosen identifier, which accurately describes what's going on:

            number_as_string = str(number)
            ...
            for char in number_as_string:

But we don't need it, and keeping a temp var in mind, if only for a moment, is slightly distracting. Do it habitually and the effect adds up. Better to phrase it as:

            for char in str(number):

The .append() thing is nice as far as it goes, feel free to retain it as-is. A list comprehension would have been more natural here:

            char_list = [int(char)  for char in str(number)]

And since that sort of thing, f(x) for x in xs, comes up all the time, there's a standard idiom for it:

            char_list = list(map(int, str(number)))

tricky assumptions

I find this code to be a little trickier than necessary:

            first_part = char_list[0] * 10
            second_part = char_list[1]

Why? Well, it relies on this pre-condition to avoid crashing: 9 < number. And as a practical matter it relies on bounded value for correctness: 9 < number < 100.

If I am doing Local Analysis, if I am just looking at the loop, that first pre-condition isn't obvious, it doesn't fall out of mechanical reasoning on just that portion of code. I'd have to look back to the init code to draw that conclusion. In a bigger project there may be greater distance between those two chunks of code, and greater chance that a maintenance engineer inadvertently introduces a bug during an edit of one or the other.

We don't just write code that is Correct. We strive to write code which is Obviously correct.

You might prefer a more robust expression like this one:

            units_digit: int = number % 10

teens

        if first_part == 10:
            spelled_numbers[number] = str(num_to_words_first_numbers[second_part]) + "teen"

Nice! This let us omit "regular" teens such as "fourteen", good. Still slightly tricky, but definitely worth it.

Not sure why we're calling str( ... ), though, since it was a string already. Consider using these declarations up top:

    spelled_numbers: dict[int, str] = {}

    num_to_words_first_numbers: dict[int, str] = ...

It doesn't make a difference to the machine. But writing down the details might help you remember the various types, rather than trying to juggle them all in your head.

Similarly for the str(), str() in the else: clause. Though there you might prefer to format the result with an f-string:

        spelled_numbers[number] = f"{ten_by_ten[first_part]}-{num_to_words_first_numbers[second_part]}"

This code achieves its design goals.

I would be willing to delegate or accept maintenance tasks on it.

\$\endgroup\$
5
\$\begingroup\$

Prefer better data over additional logic. As you know, the difficulty of the problem lies almost entirely in the numbers less than 20. Your num_to_words_first_numbers dict covers most of that territory, but you omit a few teens, forcing you to add extra logic. That's a bad trade from the point of view of code simplicity.

If your are defining data, format it for readability. Cramming a data structure into long lines is another bad trade: it optimizes lines of code at a cost of readability and maintainability. Here's how I started when editing your code:

SMALLS = {
    0: 'zero',
    1: 'one',
    2: 'two',
    3: 'three',
    4: 'four',
    5: 'five',
    6: 'six',
    7: 'seven',
    8: 'eight',
    9: 'nine',
    10: 'ten',
    11: 'eleven',
    12: 'twelve',
    13: 'thirteen',
    14: 'fourteen',
    15: 'fifteen',
    16: 'sixteen',
    17: 'seventeen',
    18: 'eighteen',
    19: 'nineteen',
}

TENS = {
    2: 'twenty',
    3: 'thirty',
    4: 'forty',
    5: 'fifty',
    6: 'sixty',
    7: 'seventy',
    8: 'eighty',
    9: 'ninety',
}

Consider using divmod(). Your code jumps through a few conversion-to-string hoops to make its calculations. The built-in divmod() function comes in handy here.

Separate detailed computation from garden-variety iteration. This problem has two core needs: (1) the ability to take an integer and produce the corresponding words, and (2) assembling a mapping of integers to such words. If we had #1 (for example, in the form of a function), the second need would be trivial. Organize your code with that insight in mind by keeping the hard/important thing (integer to words conversion) separate from the simple/secondary thing (iteration and collection creation). Here's one way to write the needed function.

def int_to_words(n):
    if n in SMALLS:
        return SMALLS[n]
    else:
        t, n = divmod(n, 10)
        if n:
            return f'{TENS[t]}-{SMALLS[n]}'
        else:
            return TENS[t]

Next steps. (1) Add the easy function to generate the collection covering 0 through 99. (2) Raise a ValueError in int_to_words() if n not in range(0, 100). (3) Cover 0 through 999, which is not too hard if you build upon the ideas above. (4) Support negative integers (also pretty easy). (5) The sky is the limit: again, with some basic divmod() logic, you could further generalize to cover thousands, millions, etc. without too much trouble.

\$\endgroup\$
4
\$\begingroup\$

You can do it with a one-liner once defined the initial values:

zero = {0: "zero"}
ten = {10:"ten"}
units = {1:"one", 2:"two", 3:"three", 4:"four", 5:"five", 6:"six", 7:"seven", 8:"eight", 9:"nine"}
teens = {11:"eleven", 12:"twelve", 13:"thirteen",14: "fourteen", 15:"fifteen", 16: "sixteen", 17: "seventeen", 18:"eighteen", 19:"nineteen"}
ten_by_ten = {20:"twenty", 30:"thirty", 40:"forty", 50:"fifty", 60:"sixty", 70:"seventy", 80:"eighty", 90:"ninety"}

hundred = zero | units | ten | teens | ten_by_ten | {unit_val + ten_val: f"{str_ten}-{str_unit}" for ten_val, str_ten in ten_by_ten.items() for unit_val, str_unit in units.items()}

print(hundred)

The solution:

  • avoids using if-else in loop, which are not so performant operations
  • uses dict comprehension which is slightly more performant than for loop
  • is much more compact and readable.

NOTE: the dictionary keys are not ordered (as it is for all dictionaries by definition).

\$\endgroup\$
6
  • \$\begingroup\$ However it also prints twenty-zero, thirty-zero, etc. \$\endgroup\$
    – Christoph
    Commented Nov 29, 2023 at 10:44
  • \$\begingroup\$ You're right, I updated the answer \$\endgroup\$ Commented Nov 29, 2023 at 11:54
  • 3
    \$\begingroup\$ And now it does not print twenty, thirty, forty… anymore. Ten is also absent from the beginning. \$\endgroup\$ Commented Nov 29, 2023 at 14:31
  • \$\begingroup\$ merge (|) is kind of new to me , nice \$\endgroup\$
    – pippo1980
    Commented Nov 30, 2023 at 21:48
  • \$\begingroup\$ @301_Moved_Permanently you're right, thank you. It's now fixed. \$\endgroup\$ Commented Dec 1, 2023 at 10:48
1
\$\begingroup\$

dict comprehension with library

Using existing libraries, essentially "standing on the shoulders of giants", allows us to create stronger code. num2words is a library to convert numbers to words. Even though we use an existing function, it might be valuable to look at its implementation for insight into its workings. Does the required output of dictionary suggest a dictionary comprehension?

pip install num2words
>>> from num2words import num2words
>>> word_dict = {num: num2words(num) for num in range(1, 6)}
>>> word_dict
{1: 'one', 2: 'two', 3: 'three', 4: 'four', 5: 'five'}

After installing an appropriate library and importing it, we were able to advance on the foundations laid by predecessors, leveraging their wisdom and achievements to reach greater heights.

\$\endgroup\$
8
  • 1
    \$\begingroup\$ can I post the entire library code below your answer ?? its 109 lines but maybe are a good hint for how to approach the OP question \$\endgroup\$
    – pippo1980
    Commented Nov 30, 2023 at 22:00
  • 1
    \$\begingroup\$ sorry its more that that was talking about NumWords github.com/TheAnkurGoswami/NumWords/blob/master/NumWords/… \$\endgroup\$
    – pippo1980
    Commented Dec 1, 2023 at 18:07
  • 1
    \$\begingroup\$ sorry I wrote the entire library I meant the 109 lines of code of NumWords.core referring def numwords(num: int) -> str: , actually is 109 - 68 , but I was talking about wrong lib : github.com/TheAnkurGoswami/NumWords/blob/… \$\endgroup\$
    – pippo1980
    Commented Dec 1, 2023 at 19:56
  • 1
    \$\begingroup\$ I can try to follow NumWords , num2words is already too much for me \$\endgroup\$
    – pippo1980
    Commented Dec 1, 2023 at 19:58
  • 1
    \$\begingroup\$ You mean stackoverflow.com/questions/9916878/… Importing modules in Python - best practice , or NEVER EVER IMPORT ANYTHING pypi is not safe , read all the library because it could carry malaware/nasty code ? \$\endgroup\$
    – pippo1980
    Commented Dec 1, 2023 at 20:05

Your Answer

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

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