Generadores y referencias

Las referencias en Python son un tema que a menudo se pasa por alto, incluso por los que ya no somos tan novatos. Hagamos un experimento, toma a diez desarrolladores que conozcas y pregúntales qué hay de malo en esta función.

def funcionx(*args):
    ctx = dict()
    for x in args:
        ctx['run'] = x
        yield ctx

Vale, es una pregunta con truco y la respuesta es todo o nada en dependencia de como utilices la función generadora. Si no sabes que es una función generadora porque no has llegado a ese capítulo en el libro o no te has leído la PEP 255 no importa. Aquí tienes la versión TL;DR

Los funciones generadoras son aquellas que usan yield en vez de return para devolver valores.

Cuando llamas una función generadora obtienes un generador, el cual puedes utilizar para seguir obteniendo valores de la función, haciendo llamadas que retornarán nuevos valores mientras encuentren yield (como dije antes TL;DR o mejor TL;DW). Los generadores son iterables así que generalmente se utilizan en ciclos for .. in.

>>> funcionx(1,2,3,4)
<generator object funcionx at 0x00000000061D98B8>
>>> for x in funcionx(1,2,3,4):
....    print(x)
{'run': 1}
{'run': 2}
{'run': 3}
{'run': 4}

Hasta aquí parece todo bien, funcionx retorna un objeto de tipo generador y podemos iterar sobre los resultados. Pero por alguna razón no pasa esta sencilla prueba unitaria.

class TestFuncionx(unittest.TestCase):

    def test_returns_generator(self):
        self.assertTrue(isinstance(funcionx(1,2,3,4),collections.Iterable))

    def test_yields_context(self):
        l = list(funcionx(1,2))
        self.assertEqual(len(l),2)
        # ¡¡Fatal!!
        self.assertEqual(l,[{'run': 1}, {'run': 2}])

¿Cómo es posible?. Obviamente algo está fallando en la prueba unitaria, verifiquemos manualmente.

>>> len(list(funcionx(1,2,))) == 2
True
>>> list(funcionx(1,2,))
[{'run': 2}, {'run': 2}]

Mmmmm…¿qúe?…mmmm. Calma, miremos de cerca la función, especialmente la parte en que hace:

   yield ctx

¿Recuerdas que ctx es un diccionario? ¿Recuerdas que los diccionarios en Python son referencias?

En cada ejecución del generador estamos devolviendo la misma referencia con valores modificados. Cuando utilizas la función en el ciclo for…in utilizamos el valor de ctx y lo descartamos enseguida, como no guardamos el resultado no notamos que el próximo valor que obtenemos es la misma referencia modificada.

La prueba unitaria falla porque, cada invocación del generador modifica las referencias que tenemos guardadas.

En este caso obtendremos una lista de n elementos que tienen el último valor de ctx.

Por suerte el problema tiene soluciones sencillas, podemos retornar una nueva instancia en cada invocación o simplemente crear una copia:

def funcionx(*args):
    ctx = dict()
    for x in args:
        ctx['run'] = x
        yield dict(ctx)

Y listo, no más valores extraños ni pruebas que fallan.


python

416 palabras

2017-12-15 02:00 +0200