15.3. Refactorización

Lo mejor de las pruebas unitarias exhaustivas no es la sensación que le queda cuando todos los casos de prueba terminan por pasar, o incluso la que le llega cuando alguien le acusa de romper su código y usted puede probar realmente que no lo hizo. Lo mejor de las pruebas unitarias es que le da la libertad de refactorizar sin piedad.

La refactorización es el proceso de tomar código que funciona y hacer que funcione mejor. Normalmente “mejor” significa “más rápido”, aunque puede significar también que “usa menos memoria” o “usa menos espacio en disco” o simplemente “es más elegante”. Independientemente de lo que signifique para usted, su proyecto, en su entorno, la refactorización es importante para la salud a largo plazo de cualquier programa.

Aquí, “mejor” significa “más rápido”. Específicamente, la función fromRoman es más lenta de lo que podría ser debido a esa expresión regular tan grande y fea que usamos para validar los números romanos. Probablemente no merece la pena eliminar completamente las expresiones regulares (sería difícil, y podría acabar por no ser más rápido), pero podemos acelerar la función compilando la expresión regular previamente.

Ejemplo 15.10. Compilación de expresiones regulares

>>> import re
>>> pattern = '^M?M?M?$'
>>> re.search(pattern, 'M')               1
<SRE_Match object at 01090490>
>>> compiledPattern = re.compile(pattern) 2
>>> compiledPattern
<SRE_Pattern object at 00F06E28>
>>> dir(compiledPattern)                  3
['findall', 'match', 'scanner', 'search', 'split', 'sub', 'subn']
>>> compiledPattern.search('M')           4
<SRE_Match object at 01104928>
1 Ésta es la sintaxis que ha visto anteriormente: re.search toma una expresión regular como una cadena (pattern) y una cadena con la que compararla ('M'). Si el patrón coincide, la función devuelve un objeto al que se le puede preguntar para saber exactamente qué coincidió y cómo.
2 Ésta es la nueva sintaxis: re.compile toma una expresión regular como cadena y devuelve un objeto de patrón. Observe que aquí no hay cadena con la que comparar. Compilar la expresión regular no tiene nada que ver con compararla con ninguna cadena específica (como 'M'); sólo implica a la propia expresión regular.
3 El objeto de patrón compilado que devuelve re.compile tiene varias funciones que parecen útiles, incluyendo varias que están disponibles directamente en el módulo re (como search y sub).
4 Llamar a la función search del objeto patrón compilado con la cadena 'M' cumple la misma función que llamar a re.search con la expresión regular y la cadena 'M'. Sólo que mucho, mucho más rápido. (En realidad, la función re.search simplemente compila la expresión regular y llama al método search del objeto patrón resultante por usted).
nota
Siempre que vaya a usar una expresión regular más de una vez, debería compilarla para obtener un objeto patrón y luego llamar directamente a los métodos del patrón.

Ejemplo 15.11. Expresiones regulares compiladas en roman81.py

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

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

# toRoman and rest of module omitted for clarity

romanNumeralPattern = \
    re.compile('^M?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 s:
        raise InvalidRomanNumeralError, 'Input can not be blank'
    if not romanNumeralPattern.search(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 parece muy similar, pero en realidad ha cambiado mucho. romanNumeralPattern ya no es una cadena; es un objeto de patrón que devolvió re.compile.
2 Eso significa que podemos invocar métodos directamente sobre romanNumeralPattern. Esto será mucho, mucho más rápido que invocar cada vez a re.search. La expresión regular se compila una vez y se almacena en romanNumeralPattern cuando se importa el módulo por primera vez; luego, cada vez que llamamos a fromRoman comparamos inmediatamente la cadena de entrada con la expresión regular, sin ningún paso intermedio.

¿Cuánto más rápido es compilar las expresiones regulares? Véalo por sí mismo:

Ejemplo 15.12. Salida de romantest81.py frente a roman81.py

.............          1
----------------------------------------------------------------------
Ran 13 tests in 3.385s 2

OK                     3
1 Sólo una nota al respecto: esta vez he ejecutado la prueba unitaria sin la opción -v, así que en lugar de la cadena de documentación completa por cada prueba lo que obtenemos es un punto cada vez que pasa una. (Si una prueba falla obtendremos una F y si tiene un error veremos una E. Seguimos obteniendo volcados de pila completos por cada fallo y error, para poder encontrar cualquier problema).
2 Ejecutamos 13 pruebas en 3.385 segundos, comparado con los 3.685 segundos sin precompilar las expresiones regulares. Eso es una mejora global del 8%, y recuerde que la mayoría del tiempo de la prueba se pasa haciendo otras cosa. (He probado separadamente las expresiones regulares por sí mismas, aparte del resto de pruebas unitarias, y encontré que compilar esta expresión regular acelera la búsqueda -search- una media del 54%). No está mal para una simple corrección.
3 Oh, y en caso de que se lo esté preguntando, precompilar la expresión regular no rompió nada como acabo de probar.

Hay otra optimización de rendimiento que me gustaría probar. Dada la complejidad de la sintaxis de las expresiones regulares, no debería sorprenderle que con frecuencia haya más de una manera de escribir la misma expresión. Tras discutir un poco este módulo en comp.lang.python alguien me sugirió que probase la sintaxis {m,n} con los caracteres repetidos opcionales.

Ejemplo 15.13. roman82.py

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

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

# se omite el resto del programa por claridad

#versión antigua
#romanNumeralPattern = \
#   re.compile('^M?M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)(IX|IV|V?I?I?I?)$')

#versión nueva
romanNumeralPattern = \
    re.compile('^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$') 1
1 Ahora hemos sustituído M?M?M?M? por M{0,4}. Ambas significan lo mismo: “coincidencia de 0 a 4 caracteres M”. De forma similar, C?C?C? se vuelve C{0,3} (“ coincidencia de 0 a 3 caracteres C”) y lo mismo con X e I.

Esta forma de expresión regular es un poco más corta (aunque no más legible). La gran pregunta es, ¿es más rápida?

Ejemplo 15.14. Salida de romantest82.py frente a roman82.py

.............
----------------------------------------------------------------------
Ran 13 tests in 3.315s 1

OK                     2
1 Globalemente, esta prueba unitaria funciona un 2% más rápida con esta forma de expresión regular. No suena muy excitante, pero recuerde que la función search es una parte pequeña del total de la prueba unitaria; la mayoría del tiempo se lo pasa haciendo otras cosas. (Por separado, he comprobado sólo las expresiones regulares y encontré que la función search es un 11% más rápida con esta sintaxis). Precompilando la expresión regular y reescribiendo parte de ella para que use esta nuevas sintaxis hemos mejorado el rendimiento de la expresión regular más de un 60%, y mejorado el rendimiento general de toda la prueba unitaria en más del 10%.
2 Más importante que cualquier mejora de rendimiento es el hecho de que el módulo sigue funcionando perfectamente. Ésta es la libertad de la que hablaba antes: libertad de ajustar, cambiar o reescribir cualquier parte del módulo y verificar que no hemos estropeado nada en el proceso. Esto no da licencia para modificar sin fin el código sólo por el hecho de modificarlo; debe tener un objetivo muy específico (“hacer fromRoman más rápida”), y debe ser capaz de cumplir ese objetivo sin el menor atisbo de duda sobre estar introduciendo nuevos fallos en el proceso.

Me gustaría hacer otra mejora más y entonces prometo que pararé de refactorizar y dejaré este módulo. Como ha visto repetidas veces, las expresiones regulares pueden volverse rápidamente bastante incomprensibles e ilegibles. No me gustaría volver sobre este módulo en seis meses y tener que mantenerlo. Por supuesto, los casos de prueba pasan todos así que sé que funciona, pero si no puedo imaginar cómo funciona va a ser difícil añadir nuevas características, arreglar nuevos fallos o simplemente mantenerlo. Como vio en Sección 7.5, “Expresiones regulares prolijas”, Python proporciona una manera de documentar la lógica línea por línea.

Ejemplo 15.15. roman83.py

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

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

# se omite el resto del programa por claridad

#versión antigua
#romanNumeralPattern = \
#   re.compile('^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$')

#versión nueva
romanNumeralPattern = re.compile('''
    ^                   # beginning of string
    M{0,4}              # thousands - 0 to 4 M's
    (CM|CD|D?C{0,3})    # hundreds - 900 (CM), 400 (CD), 0-300 (0 to 3 C's),
                        #            or 500-800 (D, followed by 0 to 3 C's)
    (XC|XL|L?X{0,3})    # tens - 90 (XC), 40 (XL), 0-30 (0 to 3 X's),
                        #        or 50-80 (L, followed by 0 to 3 X's)
    (IX|IV|V?I{0,3})    # ones - 9 (IX), 4 (IV), 0-3 (0 to 3 I's),
                        #        or 5-8 (V, followed by 0 to 3 I's)
    $                   # end of string
    ''', re.VERBOSE) 1
1 La función re.compile acepta un segundo argumento que es un conjunto de uno o más indicadores que controlan varias opciones sobre la expresión regular compilada. Aquí estamos especificando el indicador re.VERBOSE, que le dice a Python que hay comentarios en línea dentro de la propia expresión regular. Ni los comentarios ni los espacios en blanco alrededor de ellos se consideran parte de la expresión regular; la función re.compile los elimina cuando compila la expresión. Esta nueva versión “prolija” es idéntica a la antigua, pero infinitamente más legible.

Ejemplo 15.16. Salida de romantest83.py frente a roman83.py

.............
----------------------------------------------------------------------
Ran 13 tests in 3.315s 1

OK                     2
1 Esta versión nueva y “prolija” se ejecuta exactamente a la misma velocidad que la antigua. En realidad, los objetos de patrón compilado son los mismos, ya que la función re.compile elimina todo el material que hemos añadido.
2 Esta versión nueva y “prolija” pasa todas las mismas pruebas que la antigua. No ha cambiado nada excepto que el programador que vuelva sobre este módulo dentro de seis meses tiene la oportunidad de comprender cómo trabaja la función.