miércoles, 22 de diciembre de 2021

Los Generadores en Python

 

imagen de una señal que pone yield


Generadores en Python: codificación y ventajas.

Se componen de tres elementos:

  1. La instrucción yield
  2. Un bucle para recorrerlos.
  3. Una variable que actúa como contenedor.

Un generador en Python se parece mucho a una función normal solamente que se sustituye la palabra reservada 'return' por 'yield'. Estas funciones generadoras no tienen los datos guardados en ningún sitio sino que se van generando a medida que se solicitan.

Vamos a verlo con un ejemplo.

Creemos dos funciones aparentemente muy parecidas y las ejecutamos.

def funcion():
    return 'hola'

def generador():
    yield 'hola'
    
print(funcion())
print(generador())
hola
<generator object generador at 0x7fe7f1d5fac0>

Como ves la primera función devuelve el valor esperado 'hola' sin embargo la segunda lo que devuelve en realidad es un objeto de la clase generador.

Principales Características de los GENERADORES:

- Son estructuras que extraen valores de una función y que se almacenan en objetos iterables, que se pueden recorrer con un bucle for.

- Estos valores se almacenan de uno en uno.

- Cada vez que un generador almacena un valor, este permanece en estado pausado hasta que se solicita el siguiente valor, es lo que se conoce como suspensión de estado.

Debido al hecho de que producen un elemento cada vez, los generadores no tienen restricciones de memoria, pueden ser infinitos.

Lo mejor es que lo veamos con varios ejemplos. Imaginemos que queremos crear un generador que nos vaya devolviendo números desde el número 1 hasta un determinado número que le digamos de uno en uno. 

Observa el siguiente código:

> def genera_numeros(maximo):
>    n=1
>    while n<maximo:
>        yield n
>        n += 1

Para poder acceder a los valores de un generador lo primero que tenemos que hacer es guardar la 'iteración' en una variable contenedor (objeto iterable).

>  numeros = genera_numeros(100)

Esto nos ha creado un 'objeto generador' pero no ejecuta el código que se encuentra dentro. Si por ejemplo ahora usamos la función next() que se puede traducir como 'siguiente' 

>>> next(numeros)
1
>>> next(numeros)
2
>>> next(numeros)
3
Vemos que al invocar al generador este se ejecuta hasta llegar al yield en donde retorna el valor y espera hasta que vuelve a ser llamado, manteniendo la función en espera y sin borrar los datos locales de la función. En la siguiente llamada se ejecuta el comando siguiente al yield, dando a n el valor que ya tenia más uno es decir 2 y se repite otra vez el mismo proceso. Así hasta llegar a 100.

Como es un objeto iterable podemos usar un bucle for para recorrerlo:

>>> for numero in numeros:
...    print(numero)
... 
4
5
....
99
O también podemos usar una lista para recorrerlo. Vamos a crear otro objeto y lo vemos:

> numeros_b = genera_numeros(100)
> list(numero_b)
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 
21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 
39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 
57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 
75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 
93, 94, 95, 96, 97, 98, 99]

Otro ejemplo:

def generador():
    n = 1
    yield n

    n += 1
    yield n

    n += 1
    yield n

> g = generador()
> next(g)
 1
> next(g)
 2
> next(g)
 3

Lo que ocurre es lo siguiente. Se entra en la función generadora, en la que n=1 y se devuelve ese valor. La función se pone en estado de espera y el valor de n se guarda hasta la siguiente llamada. La segunda vez que usamos next() se entra de nuevo en la función, pero se continua su ejecución desde donde se dejo antes. Se suma 1 a la variable n y se devuelve el valor n=2. En la tercera llamada ocurre exactamente lo mismo. Una cuarta llamada daría un error porque no hay más código que ejecutar. (Habría que poner un return final vacío para que no nos diese error)

Forma Alternativa.

Los generadores también pueden ser creados de una forma mucho más sencilla y con una sola línea de código. Su sintaxis es muy similar a la compresión de listas pero utilizando paréntesis () en lugar de corchetes [].

Por ejemplo si tenemos una serie de números y queremos elevarlos al cuadrado haríamos la siguiente compresión de listas.

numeros = [2,3,7,9,12]
cuadrados = [x**2 for x in numeros]
print(cuadrados)
[4, 9, 49, 81, 144]

y su equivalente usando un generador sería:

generador_cuadrados = (x**2 for x in numeros)
print(generador_cuadrados)
<generator object <genexpr> at 0x7f6e68e4f5f0>

y como hemos visto podemos sacar sus datos mediante un bucle for o convirtiéndolo en una lista.

for i in generador_cuadrados:
    print(i)
4
9
49
81
144

La diferencia entre usar una compresión de listas y un generador es que en el caso de los generadores, los valores no están almacenados en memoria, sino que se van generando al vuelo lo que es una de las principales ventajas de usar generadores. Piensa que un mundo como el actual, en lo que predominan los BIG DATA, una lista puede tener millones de datos con lo que usar generadores que tramitan uno a uno los datos sin tenerlos que tener todos en memoria, es una gran ventaja.

YIELD FROM.

Sirve para simplificar código de los generadores en caso de utilizar bucles anidados. Por ejemplo vamos a crear un generador que nos devuelva una a una una serie de ciudades que previamente hemos definido. 

Antes comentar que cuando en python ponemos un * asterisco delante del parámetro de una función le estamos diciendo a Python que recibirá un numero indeterminado de elementos y que los va a recibir en UNA tupla. El argumento es una tupla con varios elementos, pero un argumento. Por tanto el código quedaría:

def devuelve_ciudades(*args):
    '''generador que devuelve ciudades de una en una'''
    for ciudad in args:
        yield from ciudad
       
    
ciudades=('Bilbao', 'Sevilla', 'Soria', 'León')
ciudad_devuelta = devuelve_ciudades(ciudades)
print(next(ciudad_devuelta))
print(next(ciudad_devuelta))
print(next(ciudad_devuelta))
print(next(ciudad_devuelta))
print(next(ciudad_devuelta))
Bilbao
Sevilla
Soria
León
Traceback (most recent call last):
  File "<string>", line 13, in <module>
StopIteration
# NOTA: la última llamada esta puesta a posta para mostrar que da un error StopIteration
Si solo usásemos 'yield ciudad' nos devolvería el único argumento con todas las ciudades en la primera llamada y luego daría error.

Este código es más facil que tener que usar un bucle anidado como este que seria el que tendriamos que usar si no pusiesemos un 'yield from':

def devuelve_ciudades(*args):
    '''generador que devuelve ciudades de una en una'''
    for elemento in args:
        for ciudad in elemento:
            yield ciudad