💾 Archived View for dmerej.info › blog › 0098-the-virtue-of-incremental-development.gmi captured on 2022-04-29 at 11:27:20. Gemini links have been rewritten to link to archived content

View Raw

More Information

⬅️ Previous capture (2021-11-30)

-=-=-=-=-=-=-

2019, Apr 21 - Dimitri Merejkowsky
License: CC By 4.0

Here's today challenge: can you write a command-line tool that allows converting to and from various measurements units?

For instance, you could input "3 miles in meters" and get "4828.03".

I submitted this challenge to my Python students last weekend, asking them to write the code from scratch.

1 hour later, something miraculous happened that I never would have expect.

But let me tell you the full story.


Getting started

I told the students that they could start by writing some "exploratory code".

"Just hard-code anything you have to and keep everything in the `main()` function ", I said.

After a few discussions, we agreed to only write code that converted kilometers to miles, and that we'll read the values from the command line.

Here's what we came up with:

import sys


def main():
    kilometers = float(sys.argv)
    miles = kilometers / 1.609
    print(f"{.2f}", miles)

if __name__ == "__main__":
    main()

I then pointed out that the code was not generic. Indeed, "kilometers", "miles" and "1.609" are hard-coded there.

Naming a new function

The students understood there was a three-parameters function waiting to be written. So we went to the drawing board and after a while we decided to have a function called `convert(value, unit_in, unit_out)`.

Note that we did *not* make any assumption about the *body* of the function. We just wanted to see how `main()` could become more generic, and we were still allowed to hard-code parts of the code:

def convert(value, unit_in, unit_out):
    coefficient = 1 / 1.609
    result = value * coefficient
    return result


def main():
    value = float(sys.argv[1])
    unit_in = sys.argv[2]
    unit_out = sys.argv[3]

    result = convert(value, unit_in, unit_out)
    print(f"{.2f}", result)

Some notes:

def convert(value, unit_in, unit_out):
    ...

# Usage: convert.py value unit_in unit_out
$ python3 convert.py 2 meters miles

Computing the coefficient

Now it was time to get rid of the hard-coded coefficient. This time finding a function name was easier:

def get_coefficient(unit_in, unit_out):
   ...

Then we tried to figure out how to implement it. We knew we would be needing a dictionary, but the structure of it was unknown.

"Back to the drawing board", I said. "Let's write down what the dictionary should look like".

Here's our first attempt:

units =  {
   "km": { "miles": 1/1.609, "meters": 1/1000, ....},
   "yards": { "miles": 1/1760, "meters": ..., "km": ...}
   ...
}

"This won't do", I said. "Look at what happens if we add a new measurement unit, such as `feet`".

We'll have to:

There has to be a better way!

After a short brainstorming session, we decided to limit ourselves to *distance* measurements, and to *always convert to SI units* first.

So we draw the new structure of the `units` dictionary:

# Coefficients convert from "meters"
distances = {
    "km": 1/1000,
    "yards": 1.094,
    "miles": 1/1609,
}

And then we thought about the algorithm. We found three possibilities:

"This is looking good", I said. "Let's try to implement the algorithm but just for the first case and see what happens".

Testing the algorithm

I showed my students how they could use Python's interpreter to check the get_coefficient() function was working properly.

We quickly managed to get the first case working:

def get_coefficient(unit_in, unit_out):
    # FIX ME: only works with distances for now
    # Coefficients to convert from "meters"
    distances = {
        "km": 1/1000,
        "yards": 1.094,
        "miles": 1/1609,
    }
    if unit_in == "m":
        return distances[unit_out]

>>> import conversion
>>> conversion.get_coefficient("m", "km")
0.001
>>> conversion.get_coefficient("m", "yards")
1.094

"Cool, this works", I said. "Let's see what happens when the input value is not in meters:"

def get_coefficient(unit_in, unit_out):
    # FIX ME: only works with distances for now
    # Coefficients to convert from "meters"
    distances = {
        "km": 1/1000,
        "yards": 1.094,
        "miles": 1/1609,
    }
    if unit_in == "m":
        return distances[unit_out]
    else:
        reciprocal_coefficient = 1 / distances[unit_in]
        return reciprocal_coefficient * distances[unit_out]

>>> import conversion
>>> conversion.get_coefficient("miles", "yards")
1760

"Look how readable the code is", I said. "We have a value that's called `reciprocal_coefficient` and we get it by calling 1 over something else. Isn't this nice?".

The miracle

I then pointed out that the 'else' after the return[1] was unnecessary.

1: Else after return: yea or nay?

def get_coefficient(unit_in, unit_out):
    # FIX ME: only works with distances for now
    # Coefficients to convert from "meters"
    distances = {
        "km": 1/1000,
        "yards": 1.094,
        "miles": 1/1609,
    }
    if unit_in == "m":
        return distances[unit_out]
    reciprocal_coefficient = 1 / distances[unit_in]
    return reciprocal_coefficient * distances[unit_out]

And then it happened. "Hey", one of the students said, "what if we added meters in the distances dictionary with `1` as value? We could get rid of the first `if` too!".

"Let's do it", I said:

def get_coefficient(unit_in, unit_out):
    # FIX ME: only works with distances for now
    distances = {
        "m": 1,
        "km": 1/1000,
        "yards": 1.094,
        "miles": 1/1609,
    }
    reciprocal_coefficient = 1 / distances[unit_in]
    return reciprocal_coefficient * distances[unit_out]

>>> import conversion
>>> conversion.get_coefficient("m", "m")
1
>>> conversion.get_coefficient("km", "m")
1000
>>> conversion.get_coefficient("m", "yards")
1760

And of course, this works. When `meters` is either `unit_in` or `unit_out`, all operations will involve multiplying or dividing by 1.

That was a really nice surprise for several reasons:

Lessons learned

We found a beautiful algorithm and a nice data structure, not by trying to solve *everything* at once, but by slowly building up more and more generic code, getting rid of hard-coded values one after the other, and by carefully thinking about naming.

I hope you find this approach useful, and I highly suggest you try using it next time you implement a new feature.

Cheers!

----

Back to Index

Contact me