Evilham

Evilham.com

Python: yield, generators i Deferreds

Introducció

El company Pedro està aprenent a fer anar Scrapy i em comenta espontàniament:

una cosa meravellosa d’emacs és que està tot molt ben documentat, i de seguida estàs mirant el codi font còmodament (tant sigui emacs lisp que c); hi ha algun equivalent a python? he intentat això:

(ara m’he posat amb el scrapy tutorial)

12345678910[13:49:28] $ ipython3
Python 3.9.2 (default, Feb 28 2021, 17:03:44)
Type 'copyright', 'credits' or 'license' for more information
IPython 7.20.0 -- An enhanced Interactive Python. Type '?' for help.

In [1]: help(yield)
  File "<ipython-input-1-b8899ae5635b>", line 1
    help(yield)
         ^
SyntaxError: invalid syntax

El poc que sé de Scrapy és que és una eina molt potent i que paga la pena aprendre (però encara no hi he tingut ocasió!) i, encara més important: que està escrita en Python, fent servir la llibreria Twisted.

Amb aquesta informació i havent après Twisted fa més anys dels que voldria admetre, entenc immediatament que el dubte darrere la pregunta, no va de yield únicament, sinó de com funciona la programació asíncrona en Twisted.

Anem a fer-hi una ullada, amb l’explicació que a mi m’hagués agradat tenir quan ho aprenia i de pas mirem els generadors!

Taula de continguts

help(yield)?

Comencem per respondre la pregunta concreta: “com puc obtenir informació sobre yield des de Python?”

Doncs no anàvem malament amb help(yield)!

Aturem-nos però a pensar què passa quan fem alguna consulta que sí funciona, com ara help(""):

>>> help("")
Help on class str in module builtins:

class str(object)
[...]

Què és help?

Des de la terminal interactiva podem esbrinar-ho:

>>> type(help)
<class '_sitebuiltins._Helper'>

És a dir, que és una instància de la classe _sitebuiltins._Helper, un objecte!

Podem fer-hi coses curioses?

>>> a = help
>>> a("")
Help on class str in module builtins:

class str(object)
[...]
>>> dir(help)
['__call__', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__',
 '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__',
 '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__',
 '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__',
 '__sizeof__', '__str__', '__subclasshook__', '__weakref__']

Interessant! Veiem que implementa __call__, és a dir, és un objecte callable:

callable(obj, /)
    Return whether the object is callable (i.e., some kind of function).

    Note that classes are callable, as are instances of classes with a
    __call__() method.

I per què no funciona help(yield)?

Ara que sabem que help és un objecte, i que és un callable, entenem que help(yield) no fa res més que cridar help amb yield com a argument…

Però yield és una paraula reservada! D’aquí que tinguem un error de sintaxi.

La manera correcta: help("yield")

Per evitar aquest error de sintaxi, podem posar la paraula yield entre cometes, d’aquesta manera és una sintaxi vàlida, i help comprovarà si correspon a un concepte conegut.

Aquesta estratègia no només ens és útil aquí, si volem saber com funciona | o + podem fer el mateix: help("|") o help("+").

>>> help("yield")
The "yield" statement
*********************

   yield_stmt ::= yield_expression

A "yield" statement is semantically equivalent to a yield expression.
The yield statement can be used to omit the parentheses that would
otherwise be required in the equivalent yield expression statement.
For example, the yield statements

   yield <expr>
   yield from <expr>

are equivalent to the yield expression statements

   (yield <expr>)
   (yield from <expr>)

Yield expressions and statements are only used when defining a
*generator* function, and are only used in the body of the generator
function.  Using yield in a function definition is sufficient to cause
that definition to create a generator function instead of a normal
function.

For full details of "yield" semantics, refer to the Yield expressions
section.

Ús normal de yield en Python

El text de help("yield") ens explica que es fa servir en funcions generadores, però no hi entra molt en detall. Tot i que podríem seguir amb la documentació amb help, ja és útil canviar a explicacions més casolanes!

Una funció generadora de nombres de Fibonacci

def fib():
    """Generador de nombres de Fibonacci"""
    a = 1
    b = 1
    while True:
        yield a
        (a, b) = (b, a + b)

La faríem servir així:

>>> a = fib()
>>> type(a)
<class 'generator'>
>>> next(a)
1
>>> next(a)
1
>>> next(a)
2
>>> next(a)
3
>>> next(a)
5
>>> next(a)
8
>>> next(a)
13

Si ens hi fixem, la funció fib conté un bucle infinit (while True)! Però a = fib() retorna de seguida! No només això, a acaba essent de tipus generator.

La màgia és que aquest generator és iterable, en particular podem anar-hi avançant amb next per obtenir el següent nombre de Fibonacci.

Només es generen exactament aquells nombres de Fibonacci que necessitem.

Aquest concepte és força útil i en Python modern es fa servir moltíssim.

Hi ha maneres molt, i molt interessants de fer-los servir :-) però no era el tema, el tema era per què cal yield amb Scrapy, i amb Twisted.

Per què cal el yield amb Scrapy/Twisted?

Doncs… Perquè a Python no hi havia async i await fa 20 anys!

La primera versió de Twisted va sortir al 2002. Aleshores el concepte de programació asíncrona no era tan comú com ho és ara, i per descomptat Python no ho suportava.

Parèntesi de programació asíncrona

Ja que hem mencionat el concepte un parell de vegades, caldrà il·lustrar-ho breument:

Tradicionalment un programa s’executa de forma seqüencial, és a dir en l’ordre en què estan escrites les instruccions.

En el món real però, especialment en el món de les xarxes, fer-ho d’aquesta manera és altament ineficient.

Ja que parlem de Scrapy, pensem en la tasca “descarregar la primera plana de 1000 pàgines web”.

Si ho fem de manera seqüencial, trigarem la suma de tots els temps de descàrrega, és a dir, si es triga 1 segon per cada pàgina, trigarem 1000 segons.

Si ho fem de manera asíncrona, es facilita paral·lelitzar aquesta tasca, el que fem és començar la descàrrega d’una pàgina web i rebre una notificació / “callback” quan aquesta descàrrega finalitza. Així, si podem fer 10 descàrregues paral·leles i triguem 1 segon per descàrrega, la mateixa tasca trigarà 100 segons (100 blocs de 1 segon amb 10 descàrregues per bloc).

Programació asíncrona en Python modern

En Python modern es pot fer programació asíncrona sense necessitar altres llibreries, aquesta implementació va estar molt influenciada per les maneres de fer de Twisted i els 10 anys d’experiència mantenint aquest tipus de codi en Python.

Molt per sobre, per definir una funció asíncrona:

async def f():
    # Pausa l'execució fins que `g` retorni
    b = await g()
    # Segueix fent altres coses
    return b**2

Podem fer servir async def per marcar la funció f com a asíncrona, i en el cos d’aquesta funció podem fer servir await per “esperar” a que altres funcions asíncrones acabin, i així poder escriure codi que sembli seqüencial.

En executar f(), no obtenim directament el resultat, sinó que obtenim una coroutine (segons com una Promise).

Aquests són els noms que més es fan servir avui dia per aquests conceptes, en part perquè es van popularitzar amb Node.js al 2009.

Programació asíncrona amb Twisted

No és molt diferent! Però per aconseguir-ho va caldre abusar conceptes que ja estaven disponibles en Python aleshores, com ara els generators!

from twisted.internet import defer

@defer.inlineCallbacks
def f():
    # Pausa l'execució fins que `g` retorni
    b = yield g()
    # Segueix fent altres coses
    defer.returnValue(b**2)

Podem fer servir el decorador defer.inlineCallbacks per “marcar la funció f com a asíncrona”, i en el cos d’aquesta funció podem fer servir yield per “esperar” a que altres funcions asíncrones acabin, i així poder escriure codi que sembli seqüencial.

En executar f(), no obtenim directament el resultat, sinó que obtenim un twisted.internet.defer.Deferred.

Conceptualment molt semblant, però força diferent a la vista. Per què?

Doncs en no tenir els conceptes dintre del llenguatge, desenvolupadors com Glyph Lefkowitz van tenir idees molt bones; si ens hi fixem, fer servir yield al cos de la funció, vol dir que f retorna un generator i no pas un resultat.

De fet, no tenim ben bé una manera de marcar “el que f retorna”, perquè cada operació que fem de manera asíncrona necessitarà un yield.

D’aquí que necessitem ambdós: defer.inlineCallbacks i defer.returnValue.

Amb el decorador defer.inlineCallbacks, el que fem és convertir aquesta funció, en quelcom que ens retorna un Deferred, i amb defer.returnValue el que fem és especificar quin serà el valor de retorn d’aquest Deferred.

Els Deferreds, com les Promises, en el fons són molt equivalents, són un objecte de Python, que ens representa un procés o càlcul que encara no ha finalitzat.

A aquests objectes els podem afegir manualment callbacks, per tal d’encadenar processos o podem fer servir yield dintre de funcions marcades com a asíncrones amb defer.inlineCallbacks per tal d’esperar el seu resultat.

Conclusió

La sintaxi “nova” de Python per programació asíncrona és molt bonica!

I Twisted, en ser una llibreria tan innovadora, encara no se’n beneficia al 100%. En part és perquè el canvi de Python 2 a Python 3 va trigar força, perquè la base de codi és molt extensa (hi ha molts protocols implementats), en part també perquè l’ecosistema async a Python va trigar en estabilitzar-se.

La realitat és que a dia d’avui es pot escriure codi que faci servir Twisted amb la sintaxi nova! Tot i això, molt sovint és útil saber d’on venim :-) perquè la compatibilitat no és perfecta, i a vegades ens trobem amb la necessitat d’escriure codi amb la sintaxi antiga; a més a més, entenent això podrem entendre els tutorials i el codi de les llibreries que necessitem, com Scrapy!

Per cert, que quedi clar que Twisted encara és un projecte rellevant! De fet, avui mateix s’ha anunciat la pre-release d’una nova versió: 22.8.0.