Posts | Tags | Categories | Archive

Clausuras en python - Parte 2

Ámbitos anidados

La importancia de disponer de clausuras va más allá de saber dónde se evalúa la función. Si fuera posible encapsular una función junto con su propio entorno de ejecución, podríamos conseguir que la función tenga “memoria” o, dicho de otro modo, que sea capaz de conservar sus propios estados entre llamadas a la función. Este empaquetado de función y entorno de ejecución se denomina a veces clausuras verdaderas y suele ser la principal característica de los llamados Lenguajes Funcionales.

En python podemos crear estas clausuras verdaderas con *funciones anidadas, donde una función está definida dentro del ámbito de la otra.

Un ejemplo sencillo:

def incr(n):
    def aux(x):
        return x + n

    return aux


inc5 = incr(5)

print(inc5(10))  # -->15

Como resultado se devuelve la función aux, definida dentro del ámbito de incr y que emplea de éste la variable n. Internamente, se conserva la referencia a la variable n, pero no será accesible desde fuera de la función aux. Hemos podido empaquetar la función junto con el entorno donde se definió.

Pongamos otro ejemplo:

def count():
    num = 0

    def aux():
        num += 1
        return num

    return aux


c1 = count()

c1()  # --> 1
c1()  # --> 2
c1()  # --> 3

Si pruebas este código te dará error. La función anidada aux intenta modificar la variable num. Para este caso, la variable se crea dentro del ámbito más interno, en lugar de usar la variable disponible. Y como se intenta modificar la variable antes de asignarle un valor, entonces se produce el error.

Como solución, podríamos hacer la variable num global para que fuera accesible por todos los ámbitos. Pero esta solución no es buena ya que nos abriría el empaquetado. Para python3 podríamos declarar la variable como nonlocal para que se busque en los ámbitos superiores:

def count():
    num = 0

    def aux():
        nonlocal num
        num += 1
        return num

    return aux

Como solución para salir del paso, se puede evitar la reasignación de variables. Por ejemplo, usando una lista:

def count():
    num = [0]

    def aux():
        num[0] += 1
        return num[0]

    return aux

Ya sé que no es muy elegante, pero hay otras formas de hacerlo mejor.

Generadores

Una de las formas más comunes de usar clausuras es a través de generadores. Básicamente, son funciones que en lugar de usar return utilizan yield para devolver un valor. Entre invocaciones, se conserva el entorno de ejecución y continúan desde el punto desde donde estaba. Para el ejemplo anterior:

def count():
    num = 0
    while True:
        num += 1
        yield num


c1 = count()
next(c1)  # --> 1
next(c1)  # --> 2

Objetos funciones

En los ejemplos que hemos visto, podríamos tener varias clausuras de una misma función. Si hemos hecho bien las tareas, la ejecución de estas clausuras son independientes:

c1 = count()
c2 = count()

next(c1)  # -->1
next(c1)  # -->2
next(c2)  # -->1
next(c2)  # -->2
next(c1)  # -->3

Con ello es posible establecer una analogía con clases y objetos. La definición de la función sería la clase y la clausura la instancia de la clase.

¿Y si lo hacemos posible? En python se denominan callables a todo objeto que tenga un método __call__, comportándose como si fueran funciones (Functores). Contruyamos una callable que funcione como una función con clausura:

class Count(object):
    def __init__(self):
        self.num = 0

    def __call__(self):
        self.num += 1
        return self.num


c1 = Count()
c1()  # -->1
c1()  # -->2
c1()  # -->3

Sin duda es la manera más elegante de usar clausuras que tenemos en python. Evita muchos problemas y nos da una gran potencia a la hora de resolver algunos problemas.

Por ejemplo: imagina que queremos recorrer una lista de números, excluyendo los que sean pares, y siempre que la suma total de los números que ya hemos visitado no supere cierto límite.

En una primera aproximación se podría crear un generador:

def recorr(lista, maximo):
    total = 0
    for i in lista:
        if i % 2 != 0:
            if total + i < maximo:
                total += i
                yield i
            else:
                break


recorr([3, 6, 7, 8, 11, 23], 30) #-->[3,7,11]

Está bien, pero no es fácil de usar. Aunque sólo necesitemos algunos elementos, seguramente estemos obligados a crear una lista completa con todos los valores1. Encima, no tenemos acceso a la variable total para saber cuánto han sumado el resultado.

Una alternativa con objetos funciones, mucho más elegante:

class RecorrFunc(object):
    def __init__(self, maximo):
        self.maximo = maximo
        self.total = 0

    def filter(self, item):
        res = item % 2 != 0 and self.total + item < self.maximo
        if res:
            self.total += item
        return res

    def __call__(self, lista):
        return [x for x in lista if self.filter(x)]


recorr = RecorrFunc(30)
recorr([3, 6, 7, 8, 11, 23])  # -->[3,7,11]
print(recorr.total)  # -->21

Las posibilidades de los objetos función son muchas. Del mismo modo que se devuelve una lista, sería posible devolver un iterador. Empleando las funciones del módulo itertools, y algunos trucos más, podríamos aplicar los principios de la programación funcional en python sin problemas.

Pero éso lo veremos en próximos artículos.


  1. No sabemos de antemano cuántos items vamos a obtener. Si, por ejemplo, necesitamos sólo los tres primeros, tendremos que iterar elemento a elemento hasta llegar a los tres que necesitamos o, bien, hasta que quede exhausto el iterador. Con la solución con funtores el proceso es mucho más directo y eficiente. 

© Chema Cortés. Built using Pelican. Theme is subtle by Carey Metcalfe. Based on svbhack by Giulio Fidente.