15.4. Epílogo

El lector inteligente leería la sección anterior y la llevaría al siguiente nivel. El mayor dolor de cabeza (y lastre al rendimiento) en el programa tal como está escrito es la expresión regular, que precisamos porque no tenemos otra manera de hacer disección de un número romano. Pero sólo hay 5000; ¿por qué no podemos generar una tabla de búsqueda una vez y luego limitarnos a leerla? La idea se vuelve aún mejor cuando nos damos cuenta de que no necesitamos usar expresiones regulares para nada. Mientras creamos la tabla para convertir enteros a números romanos podemos construir la tabla inversa que convierta romanos a enteros.

Y lo mejor de todo, ya tenemos un juego completo de pruebas unitarias. Ha cambiado la mitad del código del módulo, pero las pruebas unitarias siguen igual, así que podrá probar si su código funciona igual de bien que el original.

Ejemplo 15.17. roman9.py

Este fichero está disponible en py/roman/stage9/ dentro del directorio de ejemplos.

Si aún no lo ha hecho, puede descargar éste ejemplo y otros usados en este libro.

#Define exceptions
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass

#Roman numerals must be less than 5000
MAX_ROMAN_NUMERAL = 4999

#Define digit mapping
romanNumeralMap = (('M',  1000),
                   ('CM', 900),
                   ('D',  500),
                   ('CD', 400),
                   ('C',  100),
                   ('XC', 90),
                   ('L',  50),
                   ('XL', 40),
                   ('X',  10),
                   ('IX', 9),
                   ('V',  5),
                   ('IV', 4),
                   ('I',  1))

#Create tables for fast conversion of roman numerals.
#See fillLookupTables() below.
toRomanTable = [ None ]  # Skip an index since Roman numerals have no zero
fromRomanTable = {}

def toRoman(n):
    """convert integer to Roman numeral"""
    if not (0 < n <= MAX_ROMAN_NUMERAL):
        raise OutOfRangeError, "number out of range (must be 1..%s)" % MAX_ROMAN_NUMERAL
    if int(n) <> n:
        raise NotIntegerError, "non-integers can not be converted"
    return toRomanTable[n]

def fromRoman(s):
    """convert Roman numeral to integer"""
    if not s:
        raise InvalidRomanNumeralError, "Input can not be blank"
    if not fromRomanTable.has_key(s):
        raise InvalidRomanNumeralError, "Invalid Roman numeral: %s" % s
    return fromRomanTable[s]

def toRomanDynamic(n):
    """convert integer to Roman numeral using dynamic programming"""
    result = ""
    for numeral, integer in romanNumeralMap:
        if n >= integer:
            result = numeral
            n -= integer
            break
    if n > 0:
        result += toRomanTable[n]
    return result

def fillLookupTables():
    """compute all the possible roman numerals"""
    #Save the values in two global tables to convert to and from integers.
    for integer in range(1, MAX_ROMAN_NUMERAL + 1):
        romanNumber = toRomanDynamic(integer)
        toRomanTable.append(romanNumber)
        fromRomanTable[romanNumber] = integer

fillLookupTables()

¿Cómo es de rápido?

Ejemplo 15.18. Salida de romantest9.py frente a roman9.py


.............
----------------------------------------------------------------------
Ran 13 tests in 0.791s

OK

Recuerde, el mejor rendimiento que llegó a conseguir con la versión original fue de 13 pruebas en 3.315 segundos. Por supuesto, no es una comparación completamente justa por que esta versión tardará más en importar (cuando llena las tablas de búsqueda). Pero como la importación se hace una sola vez, esto es obviable a la larga.

¿La moral de la historia?