17.7. plural.py, fase 6

Ahora está listo para que le hable de generadores.

Ejemplo 17.17. plural6.py


import re

def rules(language):                                                                 
    for line in file('rules.%s' % language):                                         
        pattern, search, replace = line.split()                                      
        yield lambda word: re.search(pattern, word) and re.sub(search, replace, word)

def plural(noun, language='en'):      
    for applyRule in rules(language): 
        result = applyRule(noun)      
        if result: return result      

Esto usa una técnica llamada generadores que no voy siquiera a intentar explicar hasta que eche un vistazo antes a un ejemplo más simple.

Ejemplo 17.18. Presentación de los generadores

>>> def make_counter(x):
...     print 'entering make_counter'
...     while 1:
...         yield x               1
...         print 'incrementando x'
...         x = x + 1
...     
>>> counter = make_counter(2) 2
>>> counter                   3
<generator object at 0x001C9C10>
>>> counter.next()            4
entering make_counter
2
>>> counter.next()            5
incrementando x
3
>>> counter.next()            6
incrementando x
4
1 La presencia de la palabra reservada yield en make_counter significa que esto no es una función normal. Es un tipo especial de función que genera valores uno por vez. Puede pensar en ella como una función de la que se puede salir a la mitad para volver luego al punto donde se dejó. Invocarla devolverá un generador que se puede usar para crear valores sucesivos de x.
2 Para crear una instancia del generador make_counter basta invocarla como cualquier otra función. Advierta que esto no ejecuta realmente el código de la función. Lo sabemos porque la primera línea de make_counter es una sentencia print, pero aún no se ha mostrado nada.
3 La función make_counter devuelve un objeto generador.
4 La primera vez que llamamos al método next() del generador, ejecuta el código de make_counter hasta la sentencia yield y luego devuelve el valor cedido[23]. En este caso será 2, porque originalmente creamos el generador llamando a make_counter(2).
5 Invocar next() repetidamente sobre el objeto generador lo reanuda donde lo dejó y continúa hasta que encontramos la siguiente sentencia yield. La siguiente línea de código que espera ser ejecutada es la sentencia print que imprime incrementando x, y tras esto la x = x + 1 que la incrementa. Entonces entramos de nuevo en el bucle while y lo primero que hacemos es yield x, que devuelve el valor actual de x (ahora 3).
6 La segunda vez que llamamos a counter.next(), hacemos lo mismo pero esta vez x es 4. Y así en adelante. Dado que make_counter crea un bucle infinito podríamos seguir esto para siempre (teóricamente), y se mantendría incrementando x y escupiendo valores. Pero en vez de eso, veamos usos más productivos de los generadores.

Ejemplo 17.19. Uso de generadores en lugar de recursividad


def fibonacci(max):
    a, b = 0, 1       1
    while a < max:
        yield a       2
        a, b = b, a+b 3
1 La sucesión de Fibonacci es una secuencia de números donde cada uno es la suma de los dos anteriores. Empieza en 0 y 1, y crece lentamente al principio y luego cada vez más rápido. Para empezar la secuencia necesitamos dos variables: a empieza en 0, y b empieza en 1.
2 a es el número actual de la secuencia, así que lo cedemos.
3 b es el siguiente número de la secuencia así que se lo asignamos a a, pero también calculamos el valor siguiente (a+b) y se lo asignamos a b para más adelante. Observe que esto ocurre de forma paralela; si a es 3 y b es 5, entonces a, b = b, a+b dejará a con 5 (el valor previo de b) y b con 8 (la suma de los dos valores anteriores de a y b).

Ahora tenemos una función que escupe valores sucesivos de Fibonacci. Bien, podíamos haber hecho eso con recursividad pero de esta manera es más fácil de leer. Además, funciona bien con bucles for.

Ejemplo 17.20. Generadores y bucles for

>>> for n in fibonacci(1000): 1
...     print n,              2
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987
1 Podemos usar un generador como fibonacci directamente con un bucle for. El bucle for creará el objeto generador e invocará sucesivamente el método next() para obtener valores que asignar a la variable de bucle del for (n).
2 Por cada iteración sobre el bucle for n obtiene un valor nuevo de la sentencia yield de fibonacci, y todo lo que hacemos es imprimirlo. Una vez fibonacci llega al límite (a se hace mayor que max, que en este caso es 1000), entonces el bucle for termina sin novedad.

Bien, volvamos a la función plural y veamos cómo se usa esto.

Ejemplo 17.21. Generadores que generan funciones dinámicas


def rules(language):                                                                 
    for line in file('rules.%s' % language):                                          1
        pattern, search, replace = line.split()                                       2
        yield lambda word: re.search(pattern, word) and re.sub(search, replace, word) 3

def plural(noun, language='en'):      
    for applyRule in rules(language):  4
        result = applyRule(noun)      
        if result: return result      
1 for line in file(...) es una construcción común que se usa para leer ficheros línea por línea. Funciona porque file devuelve en realidad un generador cuyo método next() devuelve la siguiente línea del fichero. Esto es inmensamente útil; se me hace la boca agua sólo de pensarlo.
2 Aquí no hay magia. Recuerde que las líneas del fichero de reglas tienen tres valores separados por espacios en blanco, así que line.split() devuelve una tupla de 3 valores, y los asignamos a 3 variables locales.
3 Y entonces cedemos. ¿Qué cedemos? Una función, construida de forma dinámica con lambda, que en realidad es un closure (usa las variables locales pattern, search y replace como constantes). En otras palabras, rules es un generador que devuelve funciones de reglas.
4 Dado que rules es un generador, podemos usarlo directamente en un bucle for. La primera iteración sobre el bucle for invocaremos la función rules, que abrirá el fichero de reglas, leerá la primera línea, construirá dinámicamente una función que compare y aplique la primera regla definida en el fichero de reglas y cederá la función dinámica. En la segunda iteración sobre el bucle for se retoma la secuencia donde rules lo dejó (o sea, en mitad del bucle for line in file(...)), se lee la segunda línea del fichero de reglas, se construye dinámicamente otra función que compare y transforme según la segunda regla definida en el fichero de reglas, y la cede. Y así hasta el final.

¿En qué ha mejorado frente a la fase 5? En la fase 5 leíamos el fichero de reglas entero y creábamos una lista de todas las posibles antes siquiera de probar la primera. Ahora con los generadores podemos hacerlo todo perezosamente: abrimos el fichero, leemos la primera regla y creamos la función que la va a probar, pero si eso funciona no leemos el resto del fichero ni creamos otras funciones.

Lecturas complementarias

Footnotes

[23] yielded