Map, Reduce y Expresiones Lambda

Qué debo leer antes ?

Introducción

En esta página vamos a ver algunas funciones de orden superior que ya vienen en Python, y son muy poderosas para operar con construcciones como las listas, conjuntos, diccionarios, etc, de forma declarativa es decir especificando el qué queremos y no el cómo se debe obtener.

Estas funciones no son propias de Python, usamos este lenguaje en particular para mostrarlas, pero existen en muchos lenguajes, en especial en los del paradigma funcional.

Utilizar estas funciones nos permite repensar los problemas, en lugar de pensarlos en pasos de ir sacando cosas de las listas e ir "metiendo" en otras listas temporales, vamos a pasar a pensar en transformaciones a listas a través de funciones.

Esto nos va a permitir elevar mucho más el nivel de abstracción y tener código más conciso y expresivo. Es decir, en pocas lineas vamos a realizar grandes cosas

Filter

Es una función que, a partir de una lista, genera otra lista que solo contendrá aquellos elementos de la lista original que cumplen con una condición dada.

La condición la especificamos nosotros al utilizarla. Y esto se hace a través de otra función.

Filter es una función de orden superior, la cual recibe como parámetro otra función que será la que evaluará si un elemento cumple o no con una condición.

Así filter sirve para filtrar cualquier lista, por cualquier condición.

La firma de la función sería:

filter( funcion[x -> boolean], secuencia)

Donde la función recibe un único parámetro y devuelve un boolean (true o false)

Filtrar números positivos

Veamos un ejemplo simple: dada una lista de números queremos obtener solo aquellos que son positivos.

La forma tradicional sería:

def positivosImperativa(numeros):
    positivos = []
    for n in numeros:
        if n >= 0:
            positivos.append(n)
    return positivos
print positivosImperativa([2, -1, 4, 0, -10, -2, 6, -8])

Imprime:

[2, 4, 0, 6]

En cambio, si utilizamos filter(), podríamos escribirla como

def esPositivo(n):
    return n >= 0
def positivosConFilter(numeros):
    return filter(esPositivo, numeros)
print positivosConFilter([2, -1, 4, 0, -10, -2, 6, -8])

Que produce lo mismo.

Como vemos ahí filter recibe dos parámetros:

    1. el nombre de una función: que será la que indicará si un elemento cumple con la condición. Aquel que cumpla estará en la lista resultado, y el que no, no :)
    2. una lista (o secuencia): la que queremos filtrar.

Sí, como seguramente están pensando, seguramente, la función filter, internamente recorrerá la lista con algún for, y por cada elemento va a llamar a nuestra función y evaluar si da true o false, y hacer un append.

Pero eso no nos importa !!

Porque ya está implementado ahí adentro para siempre. No necesitamos más escribir todo ese código procedural para filtrar una lista.

De hecho, ni sabemos como está implementado internamente. Quizás, dependiendo de la estructura sería más performante recorrerla de distintas formas. Al quedar eso ahí encapsulado, después se podría implementar una mejora sin cambiar cada punto de nuestro código.

En nuestro caso, de hecho tenemos funciones tan chiquitas (de una sola linea), que podemos escribirlas ambas de corrido en una sola linea:

def esPositivo(n): return n >= 0
def positivosConFilter(numeros): return filter(esPositivo, numeros)

Y si ya existiera el esPositivo() ni siquiera hubieramos tenido que crear esa función. Entonces, si en nuestros programas hacemos funciones chiquititas que hacen pocas cosas puntuales, vamos a poder reutilizarlas, escribiendo menos código y más fácil de mantener.

Map

La función mapa se utiliza para, dada una lista obtener otra lista del mismo tamaña, transformando cada elemento de la original a través de una función que recibe por parámetro. De la misma forma que el filter recibía la función "criterio" de filtrado, aquí podemos decir que le pasamos una función que "transforma"

La firma de la función sería:

map( funcion[x -> y], secuencia)

Donde la función recibe un único parámetro y devuelve otro objeto.

Por ejemplo:

Dada una lista de números obtener la lista de esos mismos números multiplicados por 2.

Proceduralmente sería:

def multiplicadosPorDosProcedural(lista):
    multiplicados = []
    for i in lista:
        multiplicados.append(i * 2)
    return multiplicados
print multiplicadosPorDosProcedural([1, 4, 5, 2, 3, 10])

Imprime:

[2, 8, 10, 4, 6, 20]

Y ahora la implementación utilizando la función de orden superior map:

def porDos(n): return n * 2
def multiplicadosPorDos(lista):
    return map(porDos, lista)
print multiplicadosPorDos([1, 4, 5, 2, 3, 10])

Reduce

La función de orden superior reduce() se utiliza para, dada una lista, reducirla a un único elemento.

Ejemplos así en abstracto:

    • dada una lista de precios, obtener el promedio
    • data una lista de numeros obtener el máximo
    • dada una lista de personas obtener la mayor (de mayor edad)
    • dada una lista de números obtener la suma de ellos.

Para esto aplica una función reductora de dos parámetros en forma progresiva de izquierda a derecha. La función deberá recibir dos parámetros y retornar un único valor. Ese valor se utilizará para invocar la función al siguiente elemento.

La firma del reduce es:

reduce(funcion[x,y -> z], secuencia)

Veamos un ejemplo.

Dada una lista de números, sumarlos.

Eso se haría con el reduce así:

def sumar(a, b): return a + b
def sumaConReduce(numeros):
    return reduce(sumar, numeros)
print sumaConReduce([2, 3, 10, 11])

Esto imprime:

26

Fíjense que el patrón es el mismo. Le pasamos por parámetro la lista y una función. Pero en nuestro caso la función es sumar que recibe dos parámetros, y devuelve uno, el resultado de la suma.

Qué quiere decir en forma progresiva ? Y cómo es eso de, "a pares" ? Cómo se ejecuta la función ?

Para nuestro ejemplo anterior la función sumar se va a aplicar de la siguiente forma. Como dijimos, de izquierda a derecha, para la lista [2, 3, 10, 11]:

    1. sumar(2, 3) -> 5
    2. sumar(5, 10) -> 15
    3. sumar(15, 11) -> 26
  1. FIN -> 26

Como veran, la primera vez se ejecuta con los dos primeros, pero luego utiliza siempre el resultado de la función para invocarla nuevamente, junto con el siguiente elemento.

Lambdas y Closures

Con lo que vimos hasta ahora, habrán notado que es muy poderoso el uso de las funciones de orden superior. Nos permiten evitar el código procedural y repetitivo, completándo las partes de los algoritmos, utilizando funciones que pasamos como parámetro.

Entonces, va a ser muy común que empecemos a escribir nuestros programas utilizando muchísimo estas funciones.

Ahora, también habrán pensado quizás, que es molesto tener que definir otra función, por ejemplo para el criterio de "esPositivo" cuando filtrábamos con el filter.

A veces no podemos o no queremos escribir una función "aparte".

Entonces, existe el concepto de expresiones lambda. Que son (para simplificar mucho) algo así como funciones anónimas. es decir que no tienen nombre. Y es que justamente no necesitamos llamarla si no, simplemente definirla (qué hace) y pasarla por parámetro. Pero nunca nadie la llama diréctamente por su nombre,

Las expresiones lambda son en realidad mucho más que eso, pero excede al objetivo de la materia, Son parte de lo que se llama Cálculo Lambda que sería algo así como la base lógica y matemática del paradigma funcional.

Volviendo, en Python una expresión lambda se define así:

lambda nombreParametros (, otroParámetro, otro, ...) :   cuerpoDeLaFuncion

Por ejemplo, una función que dado un número indica si es positivo o no, sería:

lambda n : n >= 0

Fíjense que es parecida a nuestra función esPositivo solo que no tiene nombre. Y tampoco hace falta escribir el return.

Entonces ahora podemos reescribir nuestro ejemplo del filtrado de números positivos utilizando una expresión lambda:

def positivos(numeros): return filter(lambda n: n >= 0, numeros)

Quedando mucho más concisa.

La magia acá es que función y expresión lambda son intercambiables. Es decir que cualquier función que espera como parámetro otra función, puede ser llamada, pasándole como parámetro el nombre de una función (como usábamos hasta ahora), o bien se le puede pasar una expresión lambda (como acá).

Esto quiere decir que podemos utilizar expresiones lambda para todas las funciones de orden superior que vimos (como filter, map y reduce)

Contexto de ejecución de un lambda (variables disponibles)

La principal razón por la que uno querría utilizar lambdas al llamar a una de estas funciones de orden superior, en lugar de pasarle el nombre de la función, es que las expresiones lambda, tienen acceso, además de a los parámetros que definimos, a cualquier variable disponible en el lugar donde es definida.

Veamos un ejemplo. Queremos hacer una función genérica que dada una lista de números y un número "cota superior", filtra la lista, devolviendo solo aquellos elementos menores a la cota.

def menoresA(numeros, cota):
    return filter(lambda n: n < cota, numeros)
# ej
lista = [2, 30, 23, 6, 9, -10, 100]
print menoresA(lista, 10)

Imprime:

[2, 6, 9, -10]

Lo interesante acá es que el lambda, además de evaluar el número n que recibe como parámetro, lo está comparando contra la variable cota que es el parámetro de la función donde está definido.

Ejemplos combinatorios

//TODO: mostrar ejemplos donde combinar varias operaciones para realizar transformaciones complejos.