Wednesday, August 18, 2010

Classes and closures

Let us write some python code to transform an integer into "roman" format.
Here we use a class:

class Int2RomanConverter(object):
    weights = { 900 : 'CM', 500 : 'D', 400 : 'CD', 100 : 'C',
                90  : 'XC', 50  : 'L', 40  : 'XL', 10  : 'X',
                9   : 'IX', 5   : 'V', 4   : 'IV', 1   : 'I'}
    sorted_weights_tuples = sorted(weights.iteritems(), reverse=True)

    def __call__(self, n):
        thousands, n = divmod(n, 1000)
        roman_digits = ['M', ] * thousands

        for m, s in self.sorted_weights_tuples:
            while n >= m:
                roman_digits.append(s)
                n -= m

        return ''.join(roman_digits)

int2roman = Int2RomanConverter()

This class is a callable and works as a function. We probably should have made
the class non public (_Int2RomanConverter) and made only the int2roman function
available to clients.

The more straightforward way to write the code, would have been a simple function.
In fact, I consider this an example of premature optimization. The point was
to show how functions which rely on some sort of static data (the sorted weight)
could be constructed as classes.

In this case, I think there is no perceivable difference without the "optimization".
But an example is an example.

The very first thing coming to my mind would have been creating a function
with the sorted tuples as a default argument. Something like:

def int2roman(n, weights=sorted(
              { 900 : 'CM', 500 : 'D', 400 : 'CD', 100 : 'C',
                90  : 'XC', 50  : 'L', 40  : 'XL', 10  : 'X',
                9   : 'IX', 5   : 'V', 4   : 'IV', 1   : 'I'}.iteritems(), reverse=True)):
...

But I find this rather ugly since the weights are not a true argument,
the intention (having some data not evaluated every time) is rather masked
and users could think they can provide their own sets of weights. Well,
actually this code blatantly sucks.

Another idea was using decorators. I love decorators...
This decorator essentially adds the sorted weights to the function
as an attribute (in Python functions are objects).

def with_weights(weights):
    def _aux(f):
        f.weights = weights
        f.sorted_weights_tuples = sorted(weights.iteritems(), reverse=True)
        return f
    return _aux

The rest of the code becomes:

@with_weights({ 900 : 'CM', 500 : 'D', 400 : 'CD', 100 : 'C',
                90  : 'XC', 50  : 'L', 40  : 'XL', 10  : 'X',
                9   : 'IX', 5   : 'V', 4   : 'IV', 1   : 'I'})
def int2roman(n):
    thousands, n = divmod(n, 1000)
    roman_digits = ['M', ] * thousands

    for m, s in int2roman.sorted_weights_tuples:
        while n >= m:
            roman_digits.append(s)
            n -= m

    return ''.join(roman_digits)

This code has its merits. It's rather easy to understand, provided
that you know decorators (and these are decorators with arguments,
which is slightly more complicated). You have a function, with the
right number of arguments and you are not tempted to modify
the weights.

Then a voice in the back of my head suggested "Use the closure, Riko!"
The voice sounded a lot like the one of Alec Guinness, but that's enterely
another story.

Ideally I was already using closures, only it was not obvious.
In the decorator solution, I have a function modifying the original
function. This is not very different from having a function returning
a function. It's only less general and (in other circumstances easier
to grasp).

The class based solution is rather similar (only more verbose) to
a solution using closures. In fact I have a "function" which returns
a "function". Only the first function is a constructor and the second
function is a callable object (whose only capability is being called).

Frankly I'm ashamed. The first solution is terribly javish. The second
looks a lot like... well, python has decorators, let's use them. So,
a better code is, in my opinion:

@apply
def int2roman():
    weights = { 900 : 'CM', 500 : 'D', 400 : 'CD', 100 : 'C',
                90  : 'XC', 50  : 'L', 40  : 'XL', 10  : 'X',
                9   : 'IX', 5   : 'V', 4   : 'IV', 1   : 'I'}
    sorted_weights_tuples = sorted(weights.iteritems(), reverse=True)
    def int2roman(n):
        thousands, n = divmod(n, 1000)
        roman_digits = ['M', ] * thousands

        for m, s in sorted_weights_tuples:
            while n >= m:
                roman_digits.append(s)
                n -= m

        return ''.join(roman_digits)
    return int2roman

Notice the apply trick.

No comments: