14.5. roman.py, fase 5

Ahora que fromRoman funciona adecuadamente con entradas correctas es el momento de encajar la última pieza del puzzle: hacer que funcione adecuadamente con entradas incorrectas. Esto implica buscar una manera de mirar una cadena y determinar si es un número romano válido. Esto es inherentemente más difícil que validar entrada numérica en toRoman, pero tenemos una herramienta potente a nuestra disposición: expresiones regulares.

Si no le son familiares las expresiones regulares y no ha leído el capítulo Capítulo 7, Expresiones regulares, ahora puede ser un buen momento.

Como vio en Sección 7.3, “Caso de estudio: números romanos”, hay varias reglas simples para construir un número romano usando las letras M, D, C, L, X, V, e I. Revisemos las reglas:

  1. Los caracteres son aditivos. I es 1, II es 2, y III es 3. VI es 6 (literalmente, “5 y 1”), VII es 7, y VIII es 8.
  2. Los caracteres de unos (I, X, C, y M) se pueden repetir hasta tres veces. Para 4, necesitamos restar del siguiente carácter de cinco más cercano. No podemos representar 4 como IIII; en su lugar se representa como IV (“1 menos que 5”). 40 se escribe como XL (“10 menos que 50”), 41 como XLI, 42 como XLII, 43 como XLIII, y luego 44 como XLIV (“10 menos que 50 y 1 menos que 5”).
  3. De forma similar, para 9 necesitamos restar del siguiente carácter de uno más grande: 8 es VIII, pero 9 es IX (“1 menos que 10”), no VIIII (ya que el carácter I no se puede repetir cuatro veces). 90 es XC, 900 es CM.
  4. Los caracteres de cinco no se pueden repetir. 10 siempre se representa como X, nuncac como VV. 100 es siempre C, nunca LL.
  5. Los números romanos siempre se escriben de mayor a menor, y se leen de izquierda a derecha, así que el orden de los caracteres importa mucho. DC es 600; CD es un número completamente diferente (400, “100 menos que 500”). CI es 101; IC ni siquiera es un número romano válido (porque no se puede restar 1 directamente de 100; necesitamos escribirlo como XCIX, “10 menos que 100 y 1 menos que 10”).

Ejemplo 14.12. roman5.py

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

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

"""Convert to and from Roman numerals"""
import re

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

#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))

def toRoman(n):
    """convert integer to Roman numeral"""
    if not (0 < n < 4000):
        raise OutOfRangeError, "number out of range (must be 1..3999)"
    if int(n) <> n:
        raise NotIntegerError, "non-integers can not be converted"

    result = ""
    for numeral, integer in romanNumeralMap:
        while n >= integer:
            result += numeral
            n -= integer
    return result

#Define pattern to detect valid Roman numerals
romanNumeralPattern = '^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)(IX|IV|V?I?I?I?)$' 1

def fromRoman(s):
    """convert Roman numeral to integer"""
    if not re.search(romanNumeralPattern, s):                                    2
        raise InvalidRomanNumeralError, 'Invalid Roman numeral: %s' % s

    result = 0
    index = 0
    for numeral, integer in romanNumeralMap:
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
    return result
1 Esto es sólo una continuación del patrón que comentamos en Sección 7.3, “Caso de estudio: números romanos”. Los lugares de las decenas son XC (90), XL (40), o una L opcional seguida de 0 a 3 caracteres X opcionales. El lugar de las unidades es IX (9), IV (4), o una V opcional seguida de 0 a 3 caracteres I opcionales.
2 Habiendo codificado todas esta lógica en una expresión regular, el código para comprobar un número romano inválido se vuelve trivial. Si re.search devuelve un objeto entonces la expresión regular ha coincidido y la entrada es válida; si no, la entrada es inválida.

Llegados aquí, se le permite ser escéptico al pensar que esa expresión regular grande y fea pueda capturar posiblemente todos los tipos de números romanos no válidos. Pero no se limite a aceptar mi palabra, mire los resultados:

Ejemplo 14.13. Salida de romantest5.py frente a roman5.py


fromRoman should only accept uppercase input ... ok          1
toRoman should always return uppercase ... ok
fromRoman should fail with malformed antecedents ... ok      2
fromRoman should fail with repeated pairs of numerals ... ok 3
fromRoman should fail with too many repeated numerals ... ok
fromRoman should give known result with known input ... ok
toRoman should give known result with known input ... ok
fromRoman(toRoman(n))==n for all n ... ok
toRoman should fail with non-integer input ... ok
toRoman should fail with negative input ... ok
toRoman should fail with large input ... ok
toRoman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 12 tests in 2.864s

OK                                                           4
1 Una cosa que no mencioné sobre las expresiones regulares es que, por omisión, diferencian las mayúsculas de las minúsculas. Como la expresión regular romanNumeralPattern se expresó en letras mayúsculas, la comprobación re.search rechazará cualquier entrada que no esté completamente en mayúsculas. Así que pasa el test de entrada en mayúsculas.
2 Más importante, también pasa la prueba de entrada incorrecta. Por ejemplo, la prueba de antecedentes mal formados comprueba casos como MCMC. Como ya ha visto esto no coincide con la expresión regular, así que fromRoman lanza una excepción InvalidRomanNumeralError que es lo que el caso de prueba de antecedentes mal formados estaba esperando, así que la prueba pasa.
3 De hecho, pasan todas las pruebas de entrada incorrecta. Esta expresión regular captura todo lo que podríamos pensar cuando hicimos nuestros casos de pruebas.
4 Y el premio anticlimático del año se lo lleva la palabra “OK” que imprime el módulo unittest cuando pasan todas las pruebas.
nota
Cuando hayan pasado todas sus pruebas, deje de programar.