Python 3: iteradores en map y filter

martes, 28 de abril de 2009
Las primitivas funcionales map y filter se agregaron a Python en Enero de 1994 por un programador cuya identidad hoy se desconoce. De esta manera, lo que antes se escribía repetidas veces con un bucle puede reducirse a una única y expresiva línea de código.
lista = [1,2,3,4]

# utilizando un bucle
lista_mapeada = []
for e in lista:
    lista_mapeada.append(f(e))

lista_filtrada = []
for e in lista:
    if f(e):
        lista_filtrada.append(e)

# utilizando map y filter
lista_mapeada = map(f, lista)
lista_filtrada = filter(f, lista)
En Python 2.0 se agregaron listas por comprensión, que ofrecen una sintaxis más intuitiva para las primitivas map y filter.
lista_mapeada = [f(e) for e in lista]
lista_filtrada = [e for e  in lista if f(e)]
Si están familiarizados con lenguajes como Haskell, que utilizan evaluación lazy o perezosa conocen algunas de las ventajas de esta característica en un lenguaje de programación: la evaluación de algunas expresiones no se hace inmediatamente sino que se pospone hasta que el valor es realmente necesario.

En el contexto de las primitivas map y filter que nos conciernen, la ventaja de la evaluación lazy es que no se computan hasta tanto no se accede al contenido de las listas resultantes. Con esto en mente, en Python 2.4 se agregaron generadores por comprensión (simplemente remplazando los corchetes por paréntesis en la sintaxis anterior). El resultado es un generador: un objeto que se comporta parcialmente como una lista, dado que puede iterarse sobre el (aunque no puede indexarse y accededer directamente a su i-ésimo elemento).
gen_mapeado = (f(e) for e in lista)
gen_filtrado = (e for e  in lista if f(e))
La mejora más inmediata es la performance: en lugar de aplicar la función completamente en el momento, no se hacen los cómputos requeridos hasta que se accede al resultado. Por esta razón, la librería estándar itertools incluye primitivas imap, ifilter y varias más que devuelven generadores en lugar de listas, puesto que en muchos casos este comportamiento es deseable.

En Python 3, sencillamente se cambió el comportamiento de las primitivas convencionales para que devuelvan iteradores en lugar de listas. Así, los nuevos map, filter, zip y compañía se comportan como los anteriores itertools.imap, iterators.ifilter y iterators.izip.
# Python 3
>>> l = map(f, [1,2,3,4])
>>> l[2]
TypeError: object is unsubscriptable
El nuevo comportamiento no es compatible hacia atrás, pero puede resolverse esta diferencia sencillamente cambiando todas las llamadas map(l) (y los otros) por list(map(l)), lo cual produce un comportamiento casi idéntico en Python 3 y Python 2 (y digo casi porque si el map original devolvía una tupla o un string, pueden haber diferencias).

3 comentarios:

Pablo Antonio dijo...

Sacando a relucir mi escaso conocimiento sobre lenguajes funcionales y evaluación "lazy", pregunto: ¿Si recorro esos iteradores más de una vez (¿puedo hacerlo?) entonces voy a estar evaluando los items dos veces? ¿Es ese el comportamiento que uno debería esperar?

¿Las listas por comprensión (que son una cosa muy bonita) usando corchetes aún devuelven una lista en Python 3? ¿O ahora devuelven un generator? (Ah, asumo que "iterador" es sinónimo de "generator" en Python. Decime si me equivoco.)

GomoX dijo...

Hola Pablo,

Yo usé los términos iterador y generador de forma intercambiable, aunque no son exactamente lo mismo. Técnicamente, un generador es una expresión que devuelve un iterador, y un iterador es un objeto con una interfaz que permite iterar sobre él (básicamente, un objeto que implementa un método next()).

Efectivamente los iteradores se "gastan" (son como los cursores de una base de datos, por ejemplo), de modo que no podés recorrerlos más de una vez. En general para hacer esto se obtiene un nuevo iterador a partir del objeto que se desea recorrer. Esto no es problemático porque los iteradores, a diferencia de las listas, son muy lightweight tanto en uso de memoria como en costo de creación.

Las listas por comprensión siguen siendo listas por comprensión en Python 3, y las generator expressions (que son lo mismo pero con paréntesis en lugar de corchetes) también siguen intactas. Lo que cambió es que ahora las primitivas funcionales son equivalentes a las expresiones generadoras (pero antes eran equivalentes a las listas por comprensión).

Pablo Antonio dijo...

Gracias por contestar.

Puede ser que los iteradores sean más lightweight que las listas (según lo que decís), pero si la función con la cual hacés map (la "f") es muy costosa, y pensás recorrer los elementos afectados por la función una y otra vez, entonces no te conviene tanto usar evaluación lazy. Quizás en ese caso es mejor calcular los valores y tenerlos alojados en una lista.

No quise decir una obviedad (perdón si lo hice); sólo quise apuntar que no siempre es mejor usar los iteradores en lugar de las listas.

Publicar un comentario