functools en Python: lru_cache, partial, reduce y cache

El módulo functools de la biblioteca estándar ofrece herramientas de programación funcional para Python. Las cuatro más usadas en la práctica son @lru_cache y @cache para memoización, partial() para fijar argumentos, reduce() para acumular y @wraps para decoradores correctos.

@lru_cache y @cache: memoización automática

@lru_cache(maxsize=N) cachea los últimos N resultados. @cache (Python 3.9+) es equivalente a lru_cache(maxsize=None) y crece sin límite:

import functools
import time

@functools.lru_cache(maxsize=128)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

inicio = time.perf_counter()
print(fibonacci(40))   # 102334155
print(f"Tiempo: {time.perf_counter() - inicio:.6f}s")  # < 0.001s

# Sin cache, fibonacci(40) requiere ~2^40 llamadas; con cache, solo 41
print(fibonacci.cache_info())
# CacheInfo(hits=38, misses=41, maxsize=128, currsize=41)

# Invalida el caché si los datos cambian
fibonacci.cache_clear()

@cache para funciones puras sin límite de tamaño

import functools

@functools.cache
def factorial(n):
    if n == 0:
        return 1
    return n * factorial(n - 1)

print(factorial(10))  # 3628800
print(factorial(5))   # 120 — ya estaba en caché desde factorial(10)

partial: fijar argumentos de una función

functools.partial(func, *args, **kwargs) devuelve una nueva función con algunos argumentos ya fijados:

import functools

# Caso 1: especializar una función genérica
def potencia(base, exponente):
    return base ** exponente

cuadrado = functools.partial(potencia, exponente=2)
cubo     = functools.partial(potencia, exponente=3)

print(cuadrado(5))  # 25
print(cubo(3))      # 27

# Caso 2: adaptar una función a una interfaz específica
import json
dumps_bonito = functools.partial(json.dumps, indent=2, ensure_ascii=False)
print(dumps_bonito({"mensaje": "Hola, España"}))

# Caso 3: callbacks con parámetros extra
def procesar(item, config, verbose=False):
    pass

mi_procesador = functools.partial(procesar, config={"modo": "rapido"}, verbose=True)
# Ahora mi_procesador solo necesita el item
lista_items = ["a", "b", "c"]
resultados = list(map(mi_procesador, lista_items))

reduce: acumular valores

import functools
import operator

# reduce(función, iterable, valor_inicial_opcional)
# acumula de izquierda a derecha

# Producto de una lista
numeros = [1, 2, 3, 4, 5]
producto = functools.reduce(operator.mul, numeros)
print(producto)  # 120

# Equivalente a: ((((1 * 2) * 3) * 4) * 5)

# Máximo sin max()
maximo = functools.reduce(lambda a, b: a if a > b else b, numeros)
print(maximo)  # 5

# Aplanar lista de listas
listas = [[1, 2], [3, 4], [5, 6]]
plana  = functools.reduce(lambda a, b: a + b, listas)
print(plana)  # [1, 2, 3, 4, 5, 6]
# (aunque chain.from_iterable es más eficiente)

@wraps: decoradores que preservan metadatos

import functools

# Sin @wraps: el decorador oculta el nombre y docstring de la función
def logging_decorator(func):
    def wrapper(*args, **kwargs):
        print(f"Llamando {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@logging_decorator
def mi_funcion():
    """Docstring de mi función."""
    pass

print(mi_funcion.__name__)  # 'wrapper' — incorrecto
print(mi_funcion.__doc__)   # None — perdido

# Con @wraps: correcto
def logging_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Llamando {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@logging_decorator
def mi_funcion():
    """Docstring de mi función."""
    pass

print(mi_funcion.__name__)  # 'mi_funcion'
print(mi_funcion.__doc__)   # 'Docstring de mi función.'

En la práctica diaria, @lru_cache y @cache son los más usados de functools. partial() es excelente para configurar funciones en tiempo de setup sin lambdas. @wraps es obligatorio en cualquier decorador que escribas.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP