GENERATORS

EL MAR DE NUBES QUE PROVOCA LA LLEGADA DEL ALISIO (VIENTOS ATLÁNTICOS DE DIRECCIÓN NE-SO) Y SU PASO SOBRE LA CORRIENTE FRÍA DE LAS CANARIAS, ENVUELVE EL AMANECER DE LA FRANJA DE BARLOVENTO DEL MACIZO DE ANAGA, NOROESTE DE TENERIFE.

      Un objeto generator (recordemos que si de noche todos los gastos son pardos, para Python todo cuanto codifiquemos, de día o de noche, son objetos) es, básicamente, una función definida por el usuario, monda y lironda que, en lugar de retornar un dato único genera (y de aquí lo de generator) una secuencia de datos. Podemos ampliar la definición diciendo que son constructos o estructuras de lenguaje, como los iterators que veremos en la página siguiente y con los que se relaciona directamente, que actúa de manera unitaria, es decir, que extrae valores de uno en uno aplicando la función con cada extracción y a cada valor según los va entresacando y  evaluando, lo que se conoce como SUPERPOSICIÓN DE ESTADOS, para quienes gusten de los grandes conceptos. Cada uno de estos valores, según cumplan la condición, se van almacenando gradualmente en un objeto iterable que se crea ad hoc internamente (no lo hacemos nosotros). Ésta es la secuencia de datos autogenerada de la que hablábamos al principio: el objeto iterator de marras que, insistimos una vez más, veremos a continuación de esta página.



Quedémonos ahora con lo siguiente porque es la clave, el intríngulis del asunto: en cada ocasión que se almacena un valor, siempre en arreglo al proceso que rememoramos en la nota superior, en el iterable interno que, como ya hemos dicho,  crea el propio generator, a éste le da un telele y entra en lo que se denomina SUSPENSIÓN DE ESTADO, donde nuestra función generator se queda en modo pausa hasta que extrae el siguiente valor del iterable para su evaluación.
Los objetos generator recurren a una función interna, exclusiva, que se denomina yield, "producir", "rendir", en castellano, y que sustituye a la keyword return que todos conocemos. Esta función interna es la encargada de hacer todo el trabajo que hemos concretado más arriba: se encarga de devolver los valores producidos por la ejecución de la función generator, ítem por ítem, repetimos, y según sea el resultado de la evaluación, los almacena en el iterable interno mientras la condición se cumpla, o detiene el proceso, un auténtico frenazo en seco, si la condición no se cumple.
Cuando el flujo de ejecución de nuestra función generator alcanza yield, la ejecuta haciendo que la función generator entre en pausa (SUSPENSIÓN DE ESTADO) a la espera de la conclusión del análisis de su función interna. La función yield muestra el resultado de su evaluación sobre el valor y, si éste cumple la condición se almacena en el iterable, la función generator vuelve a llevar a cabo un nuevo ciclo de ejecución, una nueva iteración, vamos (SUPERPOSICIÓN DE ESTADOS), y el procedimiento que acabamos de describir vuelve a reiterarse hasta que la condición devenga nula o False, con lo se lanza una excepción de tipo StopIteration, que lo detiene todo. A nivel de código máquina, con cada autollamada a yield, se ejecuta el script interno que hayamos insertado en el loop for/in o while, creando un objeto iterator cada vez que se llame a generator.
¡Bufff! ¡Vaya lío, ¿verdad?!
Vamos a subir aquí un esquema que nos ayude a hacernos una idea mejor de todo esto:



   

     ES IMPORTANTE SEÑALAR QUE generator SE APLICA TANTO A LA FUNCIÓN EN SÍ COMO A SU RESULTADO. DE HECHO PODEMOS CONSIDERAR A LOS generators COMO UNA FORMA DE ITERADOR MÁS SÓLIDA Y POTENTE, EN TANTO QUE SE APOYA EN UNA SINTAXIS DE FUNCIÓN QUE, EN LUGAR DE RECURRIR A LA SENTENCIA return COMO PIE DE LA MISMA Y DEVOLVER UN VALOR, UTILIZA LA KEYWORD yield QUE LLAMA A LA FUNCIÓM INTERNA PARA CREAR UN ITERABLE.




En consecuencia, ¿dónde está o dónde radica la diferencia fundamental entre una función definida por el usuario normal y corriente y una función generator? Pues en la propia característica de la función interna yield, esto es, en su comportamiento: cuando se llama nuevamente a una función generator, habida cuenta de que tanto las posibles variables locales (las variables que se declaran dentro del ámbito de una función y que, una vez se ejecuta ésta, "mueren" con ella), como las llamadas recursivas a generator por mor del proceso en sí de iteración, que itera ítem por ítem dentro de una secuencia (iterator), llamando a la función con cada paso de ciclo, se almacenan de forma predeterminada, una función generator no inicia cada vez la ejecución del código desde la primera línea de instrucción de la función, sino que lo hace desde la instrucción inmediata precedente a la función interna yield, o a la última declaración yield en el caso de que hubiera más de una.

CABECERA DEL BARRANCO DE CHAMORGA, EXTREMO SURESTE (SOTAVENTO) DEL MACIZO DE ANAGA, MACIZO DE ANAGA, NOROESTE DE TENERIFE.

Veamos una comparativa esquemática entre una función definida por el usuario tradicional y una función generator.



Cuando llamamos a la función listaNumeros(), el control o lectura del flujo de ejecución remonta el código hasta localizar a la función en concreto que lleva su nombre (y no otra), y la pone a trabajar. Ésta se ejecutará de acuerdo a lo especificado en el código dentro del cuerpo de la función y, cuando el proceso concluye, la instrucción return devuelve un resultado.
Una vez tenemos la lista (la devolución de return), el flujo de ejecución remonta por el código hasta alcanzar la cabecera, el header, de la función, listaNumeros(), y retoma nuevamente su flujo de ejecución descendente y así una y otra vez hasta completar su cometido.
Una devolución típica de una función como ésta podría ser [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].


Cuando llamamos a la función listaNumeros(), el control o lectura del flujo de ejecución remonta el código hasta localizar a la función en concreto que lleva su nombre (y no otra), y la pone a trabajar.
Y aquí es donde empieza la diferencia: la instrucción yield genera un primer iterable con el primer valor que contiene, por ejemplo, [1].
Inmediatamente, como ya hemos comentado, el objeto generator entra estado de pausa → (el telele, la SUSPENSIÓN DE ESTADO).
En esta situación, el flujo de ejecución regresa a la llamada a la función, listaNumeros(), y de nuevo, remonta el código, localiza a la función por su nombre, y vuelve a ejecutar la función. Así el objeto generator abandona su estado de pausa (SUSPENSIÓN DE ESTADO), y obtiene el siguiente valor, por ejemplo, 2, que evalúa y, en función del resultado, almacena (o no) junto al primero, 1, en el mismo objeto iterator: [1, 2].
Una vez obtenido este segundo valor, el objeto generator vuelve a entrar en SUSPENSIÓN DE ESTADO y reproduce el proceso comentado con una nueva llamada a la función listaNumeros().

   
      LA DIFERENCIA FUNDAMENTAL ES QUE CON UNA FUNCIÓN def return TRADICIONAL EN BASE AL EJEMPLO ANTERIOR, SE NOS DEVUELVE UN ITERABLE CON TODOS LOS VALORES NUMÉRICOS A LA VEZ, MIENTRAS QUE SI RECURRIMOS A UNA FUNCIÓN generator (def  yield), SE NOS VAN DEVOLVIENDO LOS VALORES DE UNO EN UNO, AÑADIÉNDOSE DE UNO EN UNO EN SENDAS LLAMADAS A LA FUNCIÓN DENTRO DE UN MISMO ITERABLE (OBJETO iterator) TAL Y COMO PROCEDE EL MÉTODO append() DE LAS LISTAS, COMO SI LA FUNCIÓN generator REALIZASE UN BUCLE llamada ↝ valor ↺ llamada ↝ valor ↺ llamada ↝ valor, ETC. HASTA COMPLETAR LOS REQUISITOS DEL CÓDIGO DE LA FUNCIÓN, ASIMILÁNDOLA A UN POTENTE ITERADOR.


Pongamos por caso dos ejemplos reales:



Notemos como la llamada directa a la función generator no retorna resultado alguno porque no es posible adjuntar una instrucción return o una función print() para que nos lo muestre. Para tener los resultados a la vista, debemos insertar la llamada a la función descontar(x) en un bucle for/in.



Y aquí mostramos cómo es posible adjuntar varias instrucciones yield dentro de una misma función generator. Comprobamos que las ejecuta en orden descendente (el sentido del flujo de ejecución por antonomasia)
En el ámbito de una función generator podemos incluir, como se muestra en el ejemplo, tantas declaraciones yield como queramos cada uno con su pertinente instrucción. También es posible encadenar declaraciones yield dentro de un bucle for/in o en bifurcaciones if/else, pero no es una buena idea.

COSTA DE LAS ERAS, MUNICIPIO DE FASNIA, CENTRO-SUR DE TENERIFE.

Normalmente, y hasta ahora en este capítulo ha sido así, el objeto generator adopta la sintaxis de una función definida por el usuario, pero la versatilidad de Python nos permite obtener el mismo resultado pasándolo como una lista de comprensión:



Observemos que su sintaxis es similar a la de las listas de comprensión, salvo que en lugar de recurrir a corchetes, [ ], utilizamos paréntesis, ( ). Esto se debe a que en realidad, más que una lista por comprensión, es una especie de tupla por comprensión, ya que el resultado devuelto por una función es cerrado (no se puede modificar directamente) y las tuplas son secuencias de datos inmutables, como ya sabemos.
Aunque ya nos lo podemos imaginar, la inserción en una función generator de una cláusula return genera automáticamente la excepción StopIteration, la misma que se lanza a nivel interno para detener la iteración y devolver un resultado, que deteniéndose la ejecución.


Veremos en los dos ejemplos que vienen a continuación cómo se comportan dos funciones: una como objeto generator, y otra, como una función definida por el usuario, normal y corriente, tomando un objeto diccionario como iterable:


      UTILIDADES

  1. Resultan mas eficientes que las funciones definidas normales en tanto que ahorran tiempo de ejecución y consumen menos recursos.
  2. Si tuviéramos que desarrollar en nuestro código una función susceptible de generar un número infinito de valores aleatorios (listar urls con extensiones de dominio .com, direcciones IP, números enteros naturales, imágenes con extensión .jpg desde un repositorio web, etc.), las funciones generator nos facultan para ejercer cierto control sobre su obtención al efectuar valoraciones unitarias.
  3. Precisamente porque los objetos generator obtienen y evalúan de manera unitaria valor por valor, podemos evitar el generar un iterable completo con todos los valores posibles que, a lo mejor, no vamos a utilizar y tan sólo malgastan memoria y tiempo de ejecución. Así, si sólo nos interesa obtener un determinado valor (o llegar hasta él) nos bastaría con acceder al valor seleccionado en el momento mismo en que se genera por la propia función y detener el proceso, asumiendo un rol "limitador" para evitar crear código basura.
  4. Cuando desarrollamos un script que permite asignar datos o funcionalidades a cada valor específico de un iterable, por lo que nos interesa que se nos vayan devolviendo de uno en uno, por ejemplo, la asignación de una clave específica de usuario a una lista indeterminada de visitantes de nuestro sitio web.
Veamos un ejemplo de funciones comparadas, clásica y generator, para la obtención de números pares:

CLÁSICA:



GENERATOR:



AGUILILLA SOBREVOLANDO LAS CUMBRES DEL MACIZO DE TENO, EN EL ENTORNO DEL MORRO DE ABACHE, NOROESTE DE TENERIFE.

La función generator, como ya hemos dicho, construye de manera automática un objeto iterator (una lista), por lo que no nos resulta necesario declarar lista alguna en el ámbito de la función para almacenar la secuencia de datos. Nuestro yield almacenará, pues, datos (p), que irá almacenado en el objeto iterator mientras la condición se cumpla. Notemos que la sentencia  yield no es el pie de la función, situándose dentro del ámbito del loop dinámico while, mientras que en la función clásica, la declaración return sí que ejerce (y así debe ser) como pie de la función, cerrando el ámbito de la misma. Como ya hemos dicho, en una función generator se prescinde de la declaración return.
Veamos un ejemplo de uso útil de una función generator. Recurriremos para ello a la función next(iterable), es decir, una función que se ejecuta sobre el "siguiente" elemento de una secuencia que debemos pasarle como argumento devolviendo sus elementos de uno en uno. Aclarémonos antes con la función next() que, por cierto, será una de nuestras protagonistas en el capítulo siguiente dedicado al fascinante mundo de los iterators.


      CON LO QUE DEBEMOS QUEDARNOS EN UNA PRIMERA IMPRESIÓN DE LA FUNCIÓN next(iterador) QUE ACABAMOS DE MOSTRAR ES QUE FUNCIONA DE MANERA UNITARIA, VALOR POR VALOR, MIRA POR DÓNDE, EN UN PROCESO ANÁLOGO AL FUNCIONAMIENTO DE LOS OBJETOS GENERATOR. POR ESO TRABAJAN BIEN JUNTOS.


Haremos que en lugar de trabajar con un lista completa, como sucedería con una función clásica definida por el usuario, lo haga hasta un limite de 5 con sendas llamadas a la función next(iterable):



Cada vez que llamamos a next() acontece una serie de cosas: el objeto generator entra en su particular estado de suspensión (SUSPENSIÓN DE ESTADO) hasta la siguiente llamada a next() para ahorrar en recursos y ganar en eficiencia (optimización), el flujo de ejecución accede al objeto iterator todopares que, a su vez, invoca a la función pares(). Una vez aquí, obtiene (get) y "aísla" el valor generado por la función, lo almacena en el iterable y lo muestra en pantalla.
Haciendo lo mismo con una función clásica, por ejemplo, recurriendo a la función range(), se hubiera tenido que crear, primero, toda la lista (lista completa) y, en consecuencia, Python tendría que reservar todo el espacio de memoria que demanda el objeto tipo lista y luego, acceder particularmente a cada valor, con el consiguiente consumo de memoria y recursos.


   


     CON UNA FUNCIÓN generator SÓLO SE GENERAN DATOS ALMACENADOS EN UN ITERABLE CUANDO ES LLAMADA CADA VEZ:  GENERARÁ TANTOS DATOS COMO LLAMADAS EFECTUEMOS, LO QUE NOS PERMITE CONTROLAR, LA CANTIDAD/NÚMERO DE DATOS QUE NECESITAMOS, ASÍ COMO GESTIONAR MEJOR NUESTROS RECURSOS Y MEMORIA. ¡AY! ¡LA PIPETA! YA VUELVO...





      LA INSTRUCCIÓN yield from:

      
     La instrucción yield from simplifica la codificación de las funciones generator cuando tenemos que bregar con bucles (loops) anidados:





Veamos su sintaxis básica:



Vamos a verlo con un ejemplo:



En 1. recordemos que con el asterisco, *, señalamos que la función va a recibir un número indeterminado de argumentos: pueden ser 1, 2, 3, 10, 100, 1000, etc. Además, estos argumentos, sean del tipo que sean y bien fuera uno sólo o bien fueran mil, los almacenará Python a nivel interno, por defecto, en formato tupla. De aquí la explicación de por qué las funciones de Python, nativas o definidas por el usuario, terminan con paréntesis: porque, en realidad, son tuplas "camufladas" que, o bien guardan los argumentos (datos/valores) cuando se produce el paso de parámetros, o bien permanecen vacías, si no contienen datos.
En 2. creamos una variable que apunta al objeto generator (el iterable), y al que llamaremos desde la función next().
Imaginemos ahora que queremos acceder a los subelementos (en este caso, los caracteres literales que componen los nombres de las capitales: MadridM + a + d + r + i + d. Para hacerlo, teniendo en cuenta que toda string es una secuencia y, en consecuencia, un iterable, podríamos recodificar nuestro script como sigue, con un loop anidado:


¿Cómo simplificaríamos este proceso? Pues con la instrucción yield from. Prescindimos de la línea de código que llama al bucle for/in anidado, for i in item, y completamos la instrucción de retorno citando al bucle for/in principal (o padre), esto es, aquél que ocupa el primer nivel de anidación: yield from item. Veámoslo:




No hay comentarios:

Publicar un comentario