Entendiendo Yield

Para entender qué hace yield debes entender qué son los generadores. Y antes de los generadores están los iterables

Iterables

Cuando creas una lista puedes leer sus items uno a uno, y a eso se le llama iteración:


In [1]:
mylist = [1, 2, 3]
for i in mylist:
    print(i)


1
2
3

El protocolo iterador

Aquí mylist es un iterable. Python realiza los siguientes dos pasos:

1.- Obtiene un iterador desde mylist: Ejecuta iter(mylist) -> Esto retorna un objeto con un método next() (o __next__() en Python 3). [Este es un paso que mucha gente olvida decirnos]

2.- Usa el iterador para recorrer los items: Sigue llamando al método next() en el iterador devuelto en el paso 1. El valor retornado por next() es asignado a i y el cuerpo del ciclo se ejecuta. Si se llega a una excepción del tipo StopIteration significa que no hay más valores en el iterador y el ciclo termina.

La verdad es que Python realiza éstos pasos en cualquier momento que desea recorrer el contenido de un objeto - como en el ciclo for, pero podría ser el caso del método otherlist.extend(mylist) (donde otherlist es una lista de Python).

mylist es un iterable porque implementa el protocolo iterador. Puedes implementar el método __iter__() para hacer que las instancias de tu clases sean iterables. Este método debe retornar un iterador. Un iterador es un objeto con el método next(). Es posible implementar ambos __iter__() y next() en la misma clase, y hacer que __iter__() se regrese a sí mismo. Esto funcionará para clases simples, pero no cuando quieres dos iteradores recorran el objeto al mismo tiempo.

Entonces este es el protocolo iterador, muchos objetos implementan éste protocolo:

  1. Built-in lists, dictionaries, tuples, sets, files.
  2. User defined classes that implement __iter__().
  3. Generators.

Cuando usas un list comprehension también creas una lista, por lo tanto también un iterable:


In [2]:
mylist = [x*x for x in range(3)]
for i in mylist:
    print(i)


0
1
4

Todo en lo que uses "for ... in.." es un iterable: listas, cadenas, archivos. Estos iterables son útiles debido a que puedes leer los tantas veces como quieres, pero almacenas todos los valores en memoria y eso no siempre es lo que quieres hacer cuando tienes un montón de valores. Hay que notar que el ciclo for no sabe qué tipo de objeto está tratando - él sólo sigue el protocolo iterador (sí, es el duck-typing), y está feliz de obtener uno tras otro item conforme va llamado a next(). Las listas retornan sus items uno a uno, los diccionarios ({}) retornan sus keys uno a uno, los archivos retornan listas una tras otra, etc. Y los generadores retornan... bueno, es aquí donde entra yield.

Como resumen podemos decir que el protocolo iterador es un proceso que implica Iterables (implementando el método __iter__()) e Iteradores (implementando el método __next__()). Los Iterables son cualquier objeto del que puedes obtener un iterador. Los Iteradores son objetos que permiten iterar iterables. Más acerca de esto en éste artículo sobre cómo trabaja el ciclo for (en inglés).

Generators

Los Generadores son iteradores, con la salvedad de que puedes iterar en ellos sólo una vez. Esto se debe a que no almacenan todos los valores en memoria, generan los valores al vuelo:


In [3]:
mygenerator = (x*x for x in range(3))
for i in mygenerator:
    print(i)


0
1
4

Es la misma expresión, excepto que usamos () en vez de []. PERO, no puedes realizar un for i in mygenerator una segunda vez porque los generadores solo pueden ser usados una vez: calcula 0, luego lo olvida y calcula 1, y termina calculando 4, uno a uno.

Yield

Yield es una palabra clave que es usada como return, excepto que la función regresará un generador.


In [4]:
def createGenerator():
    mylist = range(3)
    for i in mylist:
        yield i*i

mygenerator = createGenerator() # crear un generator
print(mygenerator) # ¡mygenerator es un objeto!


<generator object createGenerator at 0x2c5e4b0>

In [5]:
for i in mygenerator:
    print(i)


0
1
4

Es un ejemplo inútil, pero es útil cuando sabes que una función regresará un enorme conjunto de valores que sólo necesitas leer una vez.

Para dominar yield, debes entender que cuando llamas a la función, el código que has escrito en el cuerpo de la función no se ejecuta. La función sólo retorna un objeto generator, esto es un poco truculento :-)

Entonces, tu código será ejecutado cada vez que el for usa el generador.

Ahora la parte difícil:

La primera vez que el for llama al objeto generador creado desde tu función, este ejecutará el código en la función desde el inicio hasta que llegue a yield, entonces retornará el primer valor en el ciclo. Después, cada siguiente llamada ejecutará el ciclo que escribiste en la función una vez más, y regresará el siguiente valor, hasta que no hay más valores que regresar.

El generador se considera vacío una vez que la función se ejecuta pero ya no llega a un yield. Esto puede ser debido a que el ciclo ha llegado a su fin, o porque ya no satisface un "if/else".


In [6]:
def f123():
    yield 1
    yield 2
    yield 3

for item in f123():
    print item


1
2
3

En el ejemplo anterior, si en vez de yield tuvieramos tres sentencias return sólo uno del primero sería ejecutado, y la función terminaría. pero f123() no es una función ordinaria. Cuando f123() es ejecutada, ¡ésta no regresa ninguno de los valores en la sentencia yield!, retorna un objeto Generador. También, la función no termina realmente -- se va un estado de suspensión. Cuando el ciclo for intenta iterar en el objeto iterador la función continúa desde su estado suspensivo, ejecuta la siguiente sentencia y regresa a lo que sera el siguiente item. Esto pasa hasta que función termina, es el momento en que el generador lanza una excepción StopIteration y el ciclo termina.

Así, el objeto generador es algo parecido a un adaptador - en un lado exhibe el protocolo iterador, al exponer los métodos __iter__() y next() que mantienen al ciclo for feliz. En el otro lado, sin embargo, ejecuta la función sólo lo suficiente para obtener el siguiente valor de él, y regresa al modo suspendido.

Controlando el agotamiento del generador


In [7]:
class Bank(): # creemos un banco, que a su vez crea cajeros automáticos
    crisis = False
    def create_atm(self):
        while not self.crisis:
            yield "$100"
hsbc = Bank() # cuando todo está bien el cajero te dará tanto dinero como lo desees
corner_street_atm = hsbc.create_atm()
print(corner_street_atm.next())


$100

In [8]:
print(corner_street_atm.next())


$100

In [9]:
print([corner_street_atm.next() for cash in range(5)])


['$100', '$100', '$100', '$100', '$100']

In [10]:
hsbc.crisis = True # llega la crisis, ¡no hay más dinero!
print(corner_street_atm.next())


---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
<ipython-input-10-cb648781cb69> in <module>()
      1 hsbc.crisis = True # llega la crisis, ¡no hay más dinero!
----> 2 print(corner_street_atm.next())

StopIteration: 

In [11]:
wall_street_atm = hsbc.create_atm() # esto es igual para nuevos cajeros automáticos
print(wall_street_atm.next())


---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
<ipython-input-11-f449a7157a34> in <module>()
      1 wall_street_atm = hsbc.create_atm() # esto es igual para nuevos cajeros automáticos
----> 2 print(wall_street_atm.next())

StopIteration: 

In [12]:
hsbc.crisis = False # el problema persiste, aún en la post-cris el cajero automático sigue vacío
print(corner_street_atm.next())


---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
<ipython-input-12-fdb7a57a97b0> in <module>()
      1 hsbc.crisis = False # el problema persiste, aún en la post-cris el cajero automático sigue vacío
----> 2 print(corner_street_atm.next())

StopIteration: 

In [13]:
brand_new_atm = hsbc.create_atm() # creemos un nuevo cajero para regresar al negocio
for cash in [brand_new_atm.next() for cash in range(10)]:
  print cash


$100
$100
$100
$100
$100
$100
$100
$100
$100
$100

Esto puede ser ùtil para varias cosas como controlar accesos a un recurso.

Itertools, tu mejor amigo

El módulo itertools contiene funciones especiales para manipular iterables. ¿Alguna vez quisiste duplicar un generador? ¿Encadenar dos generadores? ¿Agrupar valores en una lista anidada, con una línea de código? ¿Mapear / Comprimir sin crear otra lista?

Entonces sólo usa itertools.

¿Un ejemplo? Vamos a ver los posibles ordenes de llegada para 4 carreras de caballos:


In [14]:
import itertools
horses = [1, 2, 3, 4]
races = itertools.permutations(horses)
print(races)


<itertools.permutations object at 0x2d4eb30>

In [15]:
for v in races:
  print v


(1, 2, 3, 4)
(1, 2, 4, 3)
(1, 3, 2, 4)
(1, 3, 4, 2)
(1, 4, 2, 3)
(1, 4, 3, 2)
(2, 1, 3, 4)
(2, 1, 4, 3)
(2, 3, 1, 4)
(2, 3, 4, 1)
(2, 4, 1, 3)
(2, 4, 3, 1)
(3, 1, 2, 4)
(3, 1, 4, 2)
(3, 2, 1, 4)
(3, 2, 4, 1)
(3, 4, 1, 2)
(3, 4, 2, 1)
(4, 1, 2, 3)
(4, 1, 3, 2)
(4, 2, 1, 3)
(4, 2, 3, 1)
(4, 3, 1, 2)
(4, 3, 2, 1)

Créditos

Compilación de los mensajes de StackOverflow y 2, por e-satis y user28409, respectivamente.

Traducción: Espartaco Palma (@esparta)

Este obra está bajo una licencia Licencia Creative Commons Atribución 3.0.