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.
