Yield, generadores y corrutinas en Python
Estaba realizando uno de los problemas del Advent of Code 2019, cuando tuve la oportunidad de usar generadores y yield en Python, y para mi sorpresa, mucha gente los desconocía. Os pongo en situación. Si recordáis, el año pasado intenté publicar mis soluciones al Advent of Code comentadas aquí, aunque no logré acabar y sigo teniendo pendientes de hacer los últimos días de 2018. Este año lo he vuelto a intentar pero no he publicado nada por aquí, lo que considero que ha sido una buena decisión, ya que gastaba mucho tiempo y eran posts bastante densos de leer con poca utilidad. Sin embargo, he seguido comentando con compañeros, a través de Telegram sobre todo, diferentes soluciones. Ese día había que ejecutar 4 procesos de forma circular, uno detrás de otro pero manteniendo el estado en cada uno de ellos. Aunque existen otras formas válidas de resolverlo, considero que merece la pena echar un vistazo a los generadores y corrutinas de Python.
Empecemos por lo más sencillo de definir, un generador.
¿Qué es un generador?
Un generador es una función que permite ser pausada, devolviendo información, para posteriormente ser restaurada. En las paradas en el intercambio de datos solo se produce desde la corrutina, hacia la función que la llamó. De ahí viene su nombre: funciones "generadoras" porque van generan información.
Visto así puede incluso parecer parecido a la programación con hilos pero no os engañéis, todo esto sucede en un único hilo. Cuando se llega al punto de parada en el generador, definido dentro de esta, se guarda el estado de la función, variables locales dentro de ella y se devuelve algún dato a la función original. y se vuelve a la función original. Cuando se vuelve a restaurar la corrutina, se carga en memoria el estado de la función y se continúa hasta la siguiente parada.
Esto cambia un poco nuestra mentalidad, ya que normalmente se enseña que las funciones se llaman, con unos datos de entrada, y cuando acaban devuelven un valor.
Sintaxis de los generadores en Python
La palabra clave para implementar corrutinas es yield. yield hace una parada de la corrutina y devuelve un valor. Podemos pensar en yield como un return, pero sobre el que después sigue la función. Para controlar la corrutina disponemos de varios métodos. El primero, es usar for para ir llamando constántemente al generador.
def generator():
yield "Hola"
yield "Mundo"
def main():
f = generator()
for x in f:
print(x)
main()
Obteniendo como resultado "Hola Mundo". Usar for es la mejor manera de controlar un generador cuando tenemos solamente uno. En general, los generadores también funcionarán en cualquier función que trabaje con iteradores, como itertools.takewhile.
from itertools import takewhile
def fib():
a = 0
b = 1
while True:
c = a+b
a = b
b = c
yield c
def main():
f = fib()
a = takewhile(lambda x: x<100, f)
print(list(a))
main()
Este ejemplo, almacena en una lista todos los números de la secuencia de Fibonacci menores que 100.
Otra forma de controlar los generadores es a través de la función next. Una llamada a next ejecuta el generador hasta la siguiente parada y devuelve el contenido de yield.
def gen():
yield "Hola"
yield "Mundo"
def main():
f = gen()
print(next(f))
print(next(f))
main()
Es equivalente al Hola Mundo anterior hecho con for. Usar next es mucho más flexible pero es preferible usar for si podemos.
¿Qué es una corrutina?
Una corrutina es una función que se puede suspender su ejecución y posteriormente restaurarla. En estas paradas puede haber un intercambio de datos en ambos sentidos.
A diferencia de los generadores, aquí hay transmisión de información también de la función base a la corrutina. Esto se hace a través de la función send y el hecho de que yield devuelve valores.
Veamos este sencillo ejemplo.
def gen():
x = yield "Hola"
yield x
def main():
f = gen()
print(next(f))
y = f.send("Mundo")
print(y)
main()
Aquí podemos ver como el primer yield recibe un dato, que se envía a través de send. send es a su vez un next, así que el valor de vuelta de send es el de la siguiente parada.
En Python no llegaron las corrutinas puras hasta Python 3.3, sin embargo, eran casos muy excepcionales los que necesitaban la funcionalidad extra, el yield from. El yield from es una sintaxis que permite que la corrutina llame a otraa funciones y sean estas funciones las que se encarguen de hacer el yield. Si no lo indicásemos, las nuevas funciones serían simplemente corrutinas de las corrutinas, con yield from logramos eliminar eso.
def gen():
yield from sub()
def sub():
yield "Hola Mundo"
def main():
f = gen()
print(next(f))
main()
Los casos de uso de las corrutinas se solapan mucho con los de los objetos en el mundo OOP, ya que ambos mecanismos nos permiten mantener estado entre dos contextos. En el caso de los objetos, las llamadas a métodos permiten "restaurar" la ejecución, mientras que en las corrutinas, una llamada a next o send.
Los generadores y corrutinas existen en muchos lenguajes de corte imperativo como Python, Go o Lua y en otros de corte funcional como Scheme y Haskell.
¿Conocías los generadores y las corrutinas? ¿Los has usado alguna vez? Cuenta tu experiencia en los comentarios