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.
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.
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
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".
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?".
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:
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!
----