functools – Operaciones con funciones



functools es un módulo estándar que provee una serie de funciones que actúan sobre otras funciones. Más específicamente pueden aplicarse a cualquier objeto que implemente el método __call__. Todas las funciones del módulo son bastante diversas entre sí, compartiendo únicamente dicha particularidad: operan sobre otras funciones.

Entre ellas se encuentran partial(), para “congelar” una función con determinados argumentos; lru_cache(), para almacenar en una memoria caché el resultado de una función; singledispatch(), que provee la posibilidad de implementar funciones genéricas; entre otras.

partial

Ésta es probablemente una de las funciones más últiles en toda la librería estándar.

partial() recibe una función A con sus respectivos argumentos y retorna una nueva función B que, al ser llamada, equivale a llamar a la función A con los argumentos provistos.

>>> from functools import partial
>>> def add(a, b):
...     return a + b
...
>>> f = partial(add, 7, 5)
>>> f()
12

Si bien el ejemplo puede resultar bastante trivial, esto a menudo es sumamente útil. Por ejemplo, consideremos que una hipotética función do_work() realiza un determinado trabajo de forma asincrónica y que llamará a una función que especifiquemos cuando se haya realizado.

def callback():
    print("Trabajo terminado.")

do_work(callback)

Si tuviésemos un objeto a que quisiéramos enviarle a callback, bastaría con hacer:

def callback(a):
    print("Trabajo terminado.")

a = 1
do_work(partial(callback, a))

Esto suprime por completo la tentación de usar un objeto global que sea visible para ambos fragmentos de código.

Otra de las características de partial es la posibilidad de especificar únicamente algunos argumentos, de modo que retorna una función parcial respecto de la original. Por ejemplo, para convertir una cadena en formato hexadecimal a su número entero correspondiente se emplea:

>>> int("ff", base=16)
255

Ahora bien, si nuestro programa hace un uso intensivo de dicha conversión, podríamos crear una nueva función from_hex que remueva la necesidad de especificar base=16.

>>> from_hex = partial(int, base=16)
>>> from_hex("ff")
255

Que resulta ser una simplificación de:

>>> def from_hex(a):
...     return int(a, base=16)

Las funciones retornadas por partial() contienen el nombre de la función a la que llaman y sus respectivos argumentos.

>>> from_hex.func
<class 'int'>
>>> from_hex.args
()
>>> from_hex.keywords
{'base': 16}

La función functools.partialmethod (introducida en la versión 3.4) es similar, pero opera con métodos dentro de una clase:

from functools import partialmethod

class Window:
    def __init__(self):
        self._visible = True
    
    def set_visible(self, visible):
        self._visible = visible
    
    show = partialmethod(set_visible, True)
    hide = partialmethod(set_visible, False)

window = Window()
window.hide()

lru_cache

functools.lru_cache() (introducido en la versión 3.2) es un decorador para almacenar en una memoria caché el resultado de una función. Es decir, si una función es llamada 4 veces con los mismos argumentos, las últimas tres llamadas simplemente retornan un valor guardado en memoria, sin ejecutar realmente dicha función. Esto, en tareas “pesadas”, es una considerable optimización de memoria y velocidad.

El ejemplo más ilustrativo es una función que retorne el contenido de una URL:

from functools import lru_cache
from urllib.request import urlopen

@lru_cache()
def read_url(url):
    with urlopen(url) as r:
        return r.read()

print(len(read_url("http://recursospython.com")))
print(len(read_url("http://recursospython.com")))
print(len(read_url("http://recursospython.com")))
print(len(read_url("http://recursospython.com")))

Al ejecutar este código, en mi caso la primera llamada toma alrededor de 1 segundo, mientras que las últimas tres retornan instantáneamente.

Podemos especificar al cabo de cuántas llamadas a la función debe expirar el caché.

# El caché es válido por 6 llamadas.
@lru_cache(maxsize=6)
def read_url(url):
    # ...

Para que el caché no expire nunca, debe indicarse maxsize=None. El valor por defecto es 128.

No obstante, el caché de una función puede ser eliminado manualmente vía cache_clear():

>>> read_url.cache_clear()

Y para obtener información sobre el caché:

>>> read_url.cache_info()
CacheInfo(hits=3, misses=1, maxsize=6, currsize=1)

Nótese que funciones decoradas con lru_cache() solo pueden recibir como argumentos objetos que implementen el método __hash__ (hashable objects). Esto incluye, por ejemplo, todos los objetos inmutables de Python: un entero, una cadena, una tupla; no así una lista o un diccionario.

>>> @lru_cache()
... def f(a):
...     print(a)
...
>>> f([1, 2, 3])
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'

Para conocer sobre el algoritmo usado internamente por el decorador véase este enlace.

lru_cache() puede ser utilizado en versiones anteriores a la 3.2 vía un paquete adicional. Véase backports.functools_lru_cache.

singledispatch

singledispatch() (disponible a partir de Python 3.4) es un intento de introducir funciones genéricas (aquellas que se definen múltiples veces dependiendo del tipo de dato que reciben como argumento) al lenguaje. Los detalles de esta implementación están explicados en el documento PEP 443 — Single-dispatch generic functions.

No obstante Python está lejos de proveer una solución completa para funciones genéricas. Este método es single en el sentido de que solo considera el tipo del primer argumento para realizar el despache (dispatch), es decir, llamar a la función correspondiente.

Para ver un ejemplo, consideremos una función duplicate que, para números enteros, retorna el doble de su argumento.

>>> def duplicate(a):
...     return a * 2
...
>>> duplicate(5)
10

Ahora bien, queremos que duplicate() también aplique a cadenas, de modo que retorne dos veces el texto provisto con un espacio intermedio.

>>> from functools import singledispatch
>>> @singledispatch
... def duplicate(a):
...     return a * 2
...
>>> @duplicate.register(str)
... def _(s):
...     return " ".join((s, s))
...
>>> duplicate(5)
10
>>> duplicate("Python")
'Python Python'

Esto es lo que ha sucedido: primero, con el decorador @singledispatch definimos la función principal. Luego, utilizando @duplicate.register() definimos la misma función pero para tipos de datos específicos (en este caso, para el tipo str). Cuando llamamos a duplicate(), Python chequea el objeto pasado como argumento y según su tipo de dato llama a la función correspondiente. Si el tipo de dato no ha sido registrado específicamente vía @duplicate.register(), entonces se invoca a la principal.

Una misma función puede ser definida para dos tipos de datos. Por ejemplo, podríamos considerar como equivalentes tanto una lista como una tupla:

@duplicate.register(tuple)
@duplicate.register(list)
def _(list_or_tuple):
    # ...

register() también puede ser aplicado en forma funcional:

>>> def duplicate_str(s):
...     return " ".join((s, s))
...
>>> duplicate.register(str, duplicate_str)

Y para conocer qué función se llamará ante un determinado tipo:

>>> duplicate.dispatch(str)
<function duplicate_str at 0x00000005D309ABF8>

La ventaja de usar funciones genéricas por sobre chequear manualmente (vía type() o isinstance()) el tipo de dato de un objeto es la posibilidad de permitir al usuario registrar sus propias funciones. Por ejemplo, usando singledispatch(), la función pprint.pprint podría permitir al programador introducir nuevos tipos de datos para ser desplegados en pantalla (de hecho esto es algo que está siendo considerado).

Para utilizar singledispatch() en versiones anteriores a la 3.4, véase el paquete singledispatch.

total_ordering

Aplicado a una clase, este decorador define automáticamente tres de los cuatro métodos de comparación __lt__(), __le__(), __gt__() y __ge__(). El único requisito es definir explícitamente alguno de ellos junto con el método de equivalencia __eq__().

Por ejemplo, consideremos una clase String que, a diferencia del tipo str, define sus parámetros de comparación por la longitud de la cadena, de modo que "Hola" es igual a "Chau" ya que ambas contienen 4 caracteres.

class String:
    def __init__(self, string):
        self._string = string

    def __eq__(self, other):
        return len(self._string) == len(other._string)

>>> String("Hola") == String("Chau")
True

Solo es necesario definir un método de comparación más para que, aplicando el decorador, se generen automáticamente el resto.

@total_ordering
class String:

    def __init__(self, string):
        self._string = string

    def __eq__(self, other):
        return len(self._string) == len(other._string)
    
    def __lt__(self, other):
        return len(self._string) < len(other._string)

Comprobación:

>>> String("Hola") == String("Chau")  # __eq__
True
>>> String("Hola") > String("Chau")  # __gt__
False
>>> String("Hola") >= String("Chau")  # __ge__
True
>>> String("Hola") < String("Chau")  # __lt__
False
>>> String("Hola") <= String("Chau")  # __le__
True

Sin embargo, advierte la documentación oficial, el uso @total_ordering resulta en una menor velocidad de ejecución que su equivalente definiendo manualmente todos los métodos.

reduce

Esta función “reduce” un objeto iterable (por ejemplo una lista o una tupla) a un único objeto. Qué objeto retorna y qué operación(es) aplica sobre el iterable depende de la función que se le pase.

>>> from functools import reduce
>>> words = ["Hola", "mundo", "desde", "Python"]
>>> def add_str(a, b):
...     return " ".join((a, b))
...
>>> reduce(add_str, words)
'Hola mundo desde Python'

En el ejemplo, definimos una función que concatena dos cadenas con un espacio intermedio. Luego, la aplicamos a la lista words de forma acumulativa vía reduce(). Es “acumulativo” en el sentido de que el resultado de cada operación (en este caso add_str()) es utilizado para la siguiente.

>>> ret = add_str(words[0], words[1])
>>> ret
'Hola mundo'
>>> ret = add_str(ret, words[2])
>>> ret
'Hola mundo desde'
>>> ret = add_str(ret, words[3])
>>> ret
'Hola mundo desde Python'

cmp_to_key

El objetivo principal de esta función, introducida en la versión 3.2, es facilitar la conversión de código de Python 2 a Python 3 y básicamente se aplica (aunque no se limita) a la función sorted, que es empleada para ordenar objetos iterables como una lista o una tupla.

>>> sorted([2, 1, 4, 3])
[1, 2, 3, 4]

Si bien el comportamiento es bastante definido para elementos tales como números enteros, podría variar para otros tipos más complejos. Por ejemplo, por defecto las cadenas se ordenan teniendo en cuenta el número ordinal del primer carácter.

>>> it = ["Python", "C++", "Java"]
>>> sorted(it)
['C++', 'Java', 'Python']

(A menudo esto se interpreta erróneamente como orden alfabético.)

Sin embargo, una operación usual es ordenar las cadenas por su longitud. Para esto Python históricamente usó lo que se denominó una función de comparación cmp, para comparar dos elementos a y b, la cual debería retornar un número negativo si a < b, cero si a == b y un número positivo si a > b.

Así, para ordenar una serie de cadenas según su longitud, podríamos usar:

>>> def cmp_by_len(a, b):
...     return len(a) - len(b)
...
>>> it = ["Python", "C++", "Java"]
>>> sorted(it, cmp=cmp_by_len)
['C++', 'Java', 'Python']

Este fue el método "estándar" para definir cómo debía ordenarse un determinado tipo de datos. Con el advenimiento de Python 3, el equipo de desarrolladores aprovechó para efectuar un cambio de diseño para reemplazar la función cmp por un concepto más sencillo y eficiente llamado key.

En el nuevo paradigma, el programador ya no debería proveer una función que compare dos elementos, sino una que retorne un valor numérico para un único elemento. Luego la función de ordenación (sorted en este caso) simplemente se encargaría de ubicar los elementos según su valor numérico.

Así, nuestra operación de ordenar según la longitud de la cadena se vuelve mucho más sencilla:

>>> it = ["Python", "C++", "Java"]
>>> sorted(it, key=len)
['C++', 'Java', 'Python']

Lo que sucede es que sorted() llama a len con cada uno de los elementos. Para el elemento "Python" la función retornará 6; para "C++", 3; y para "Java", 4. Luego simplemente ordena, de menor a mayor, los resultados y la secuencia [6, 3, 4] pasa a ser [3, 4, 6], es decir, ['C++', 'Java', 'Python'].

Ahora bien, como vimos en los ejemplos, sorted() puede ordenar según una función cmp o bien una función key. Ambas conviven en Python 2 pero en Python 3 se eliminó por completo la posibilidad de usar cmp.

Lo que permite functools.cmp_to_key() es convertir automáticamente funciones del protocolo cmp al método key, de modo que funciones de ordenación muy complejas no tengan que ser reescritas por completo.

>>> from functools import cmp_to_key
>>> def cmp_by_len(a, b):
...     return len(a) - len(b)
...
>>> it = ["Python", "C++", "Java"]
>>> sorted(it, key=cmp_to_key(cmp_by_len))
['C++', 'Java', 'Python']

update_wrapper

Copia los atributos de una función A a una función B.

>>> from functools import update_wrapper
>>> def foo():
...     """Descripción de foo()."""
...     pass
...
>>> def bar():
...     pass
...
>>> update_wrapper(bar, foo)
<function foo at 0x000000890610F048>
>>> bar.__doc__
'Descripción de foo().'

Véase la documentación oficial de la función para conocer qué información se copia y potenciales usos.



Deja un comentario