functools en Python: lru_cache, cache, partial, reduce, wraps y cached_property

El módulo functools agrupa herramientas para trabajar con funciones de orden superior: decoradores de memoización, utilidades para componer y modificar funciones, y reducción de colecciones. Son piezas pequeñas, pero usarlas bien elimina código repetitivo y mejora el rendimiento sin cambiar la lógica del programa.

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

@lru_cache(maxsize=N) guarda los últimos N resultados de una función. Si la llamas con los mismos argumentos, devuelve el resultado cacheado sin ejecutar el cuerpo. @cache (Python 3.9+) es equivalente con caché ilimitada.

from functools import lru_cache, cache
import time

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

# Sin cache: 2^40 llamadas recursivas. Con cache: 40 llamadas.
inicio = time.perf_counter()
print(fibonacci(40))   # 102334155
print(f"Tiempo: {time.perf_counter() - inicio:.4f}s")  # <0.001s

# Ver estadísticas
print(fibonacci.cache_info())
# CacheInfo(hits=38, misses=41, maxsize=128, currsize=41)

# Vaciar la cache
fibonacci.cache_clear()
from functools import cache

@cache  # ilimitada, más rápida que lru_cache
def factorial(n: int) -> int:
    return 1 if n == 0 else n * factorial(n - 1)

print(factorial(10))   # 3628800
print(factorial(10))   # devuelve inmediatamente desde cache

partial() — fijar argumentos de una función

partial(func, *args, **kwargs) crea una nueva función con algunos argumentos ya fijados. Útil para adaptar funciones genéricas a contextos específicos.

from functools import partial

def potencia(base: float, exponente: float) -> float:
    return base ** exponente

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

print(cuadrado(4))   # 16.0
print(cubo(3))       # 27.0

# Ejemplo real: configurar un logger con nivel fijo
import logging
logging.basicConfig(level=logging.DEBUG)
log_debug = partial(logging.log, logging.DEBUG)
log_debug("Este mensaje va con nivel DEBUG")
from functools import partial

# Con sorted(): comparador personalizado
datos = [{"nombre": "Carlos", "edad": 30}, {"nombre": "Ana", "edad": 25}]
por_edad = partial(sorted, key=lambda x: x["edad"])
print(por_edad(datos))  # Ana primero

@wraps: preservar metadatos de la función original

Cuando escribes un decorador, la función resultante pierde su nombre y docstring originales. @wraps copia los metadatos de la función decorada a la wrapper.

from functools import wraps
import time

def cronometrar(func):
    @wraps(func)  # preserva __name__, __doc__, __module__...
    def wrapper(*args, **kwargs):
        inicio = time.perf_counter()
        resultado = func(*args, **kwargs)
        print(f"{func.__name__} tardó {time.perf_counter() - inicio:.4f}s")
        return resultado
    return wrapper

@cronometrar
def calcular_primos(limite: int) -> list[int]:
    """Devuelve todos los primos hasta limite usando la criba de Eratóstenes."""
    criba = [True] * (limite + 1)
    criba[0] = criba[1] = False
    for i in range(2, int(limite**0.5) + 1):
        if criba[i]:
            for j in range(i*i, limite + 1, i):
                criba[j] = False
    return [i for i, es_primo in enumerate(criba) if es_primo]

primos = calcular_primos(100_000)
print(calcular_primos.__name__)  # calcular_primos (no wrapper)
print(calcular_primos.__doc__)   # Devuelve todos los primos...

reduce() — acumulaciones sobre secuencias

from functools import reduce
from operator import mul

# Producto de todos los elementos
numeros = [1, 2, 3, 4, 5]
producto = reduce(mul, numeros)
print(producto)  # 120 (= 1*2*3*4*5)

# Aplanar lista de listas
listas = [[1, 2], [3, 4], [5, 6]]
plana = reduce(lambda acc, x: acc + x, listas, [])
print(plana)  # [1, 2, 3, 4, 5, 6]

# Valor máximo (mejor usar max(), pero ilustra el concepto)
valores = [3, 7, 2, 9, 1]
maximo = reduce(lambda a, b: a if a > b else b, valores)
print(maximo)  # 9

cached_property — propiedad calculada una sola vez

cached_property calcula el valor la primera vez que se accede y lo almacena como atributo de instancia. Las siguientes accesos devuelven el valor cacheado sin recalcular.

from functools import cached_property
import statistics

class Dataset:
    def __init__(self, datos: list[float]):
        self._datos = datos

    @cached_property
    def media(self) -> float:
        print("Calculando media...")
        return statistics.mean(self._datos)

    @cached_property
    def desviacion(self) -> float:
        print("Calculando desviación...")
        return statistics.stdev(self._datos)

ds = Dataset([1.0, 2.0, 3.0, 4.0, 5.0])
print(ds.media)       # Calculando media... 3.0
print(ds.media)       # 3.0 (desde cache, sin mensaje)
print(ds.desviacion)  # Calculando desviación... 1.58...

total_ordering — comparaciones completas con menos código

from functools import total_ordering

@total_ordering
class Temperatura:
    def __init__(self, celsius: float):
        self.celsius = celsius

    def __eq__(self, other) -> bool:
        return self.celsius == other.celsius

    def __lt__(self, other) -> bool:
        return self.celsius < other.celsius

# total_ordering genera automáticamente: __le__, __gt__, __ge__
t1 = Temperatura(20.0)
t2 = Temperatura(25.0)
print(t1 < t2)   # True
print(t1 <= t2)  # True
print(t2 > t1)   # True
print(sorted([Temperatura(30), Temperatura(15), Temperatura(22)],
             key=lambda t: t.celsius))

La combinación más frecuente en código de producción es @lru_cache para funciones puras con coste elevado, @wraps en todos los decoradores propios, y partial para adaptar funciones de librería a la interfaz que espera otro módulo. cached_property es especialmente útil en modelos de dominio donde los cálculos derivados son costosos y los datos no cambian después de la construcción.

COMPARTE ESTE ARTÍCULO

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