`functools.lru_cache` y `functools.cache`: memoización en Python

La memoización es una forma de trading: cambias memoria por tiempo de cómputo. Cuando una función pura recibe los mismos argumentos múltiples veces, ¿para qué recalcular? functools.lru_cache guarda los resultados indexados por los argumentos de entrada y los devuelve directamente en llamadas subsecuentes. El “LRU” (Least Recently Used) describe la política de desalojo: cuando el caché llega a maxsize, descarta las entradas usadas hace más tiempo.

@cache, disponible desde Python 3.9, es exactamente lru_cache(maxsize=None) — sin límite, sin overhead de rastrear el orden de uso. Ligeramente más rápido en llamadas individuales, pero sin protección contra crecimiento ilimitado.

El mecanismo interno es un diccionario cuyas claves son tuplas construidas a partir de los argumentos posicionales y keyword de cada llamada. Aquí está la implicación directa: todos los argumentos deben ser hashables. Pasar una lista o un dict dispara TypeError en el momento de la llamada, no al decorar.

Usas esto cuando tienes tres condiciones juntas: la función es pura (mismo input, mismo output, sin efectos secundarios), el cálculo es costoso o se repite frecuentemente, y los argumentos tienen cardinalidad razonable. La Fibonacci recursiva es el ejemplo clásico, pero en producción aparece más en parseo repetido, consultas a datos estáticos o transformaciones de configuración.

La trampa más seria es decorar métodos de instancia directamente. El primer argumento de cualquier método es self, y self pasa a formar parte de la clave del caché. El caché vive en la función del módulo, no en la instancia — lo que significa que cada instancia que llames mantiene una referencia viva dentro del caché global. La instancia nunca puede ser recolectada por el garbage collector mientras el caché exista. Memory leak garantizado en aplicaciones de larga duración.

import functools
import time
from typing import NamedTuple


# ── Caso 1: función pura con lru_cache ─────────────────────────────────

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


# ── Caso 2: @cache sin límite (Python 3.9+) ────────────────────────────

@functools.cache
def expensive_parse(text: str) -> tuple[str, ...]:
    # Simula un parseo costoso
    time.sleep(0.01)
    return tuple(word.lower() for word in text.split())


# ── Caso 3: argumento compuesto — debe ser hashable ────────────────────

class Config(NamedTuple):          # NamedTuple es hashable, dict no
    host: str
    port: int
    debug: bool

@functools.lru_cache(maxsize=32)
def build_connection_string(config: Config) -> str:
    return f"{config.host}:{config.port}?debug={config.debug}"


# ── Caso 4: patrón correcto para métodos de instancia ──────────────────

class DataProcessor:
    def __init__(self, multiplier: int):
        self.multiplier = multiplier

    def process(self, value: int) -> int:
        # El caché vive en la instancia, no en la clase.
        # Cuando la instancia muere, el caché muere con ella.
        return self._cached_process(value)

    @functools.lru_cache(maxsize=64)  # ❌ si se aplica al método directamente en la clase
    def _cached_process(self, value: int) -> int:
        # Este patrón SÍ tiene el problema: self es parte de la clave
        time.sleep(0.001)
        return value * self.multiplier


# Patrón seguro: caché por instancia con __init__
class SafeProcessor:
    def __init__(self, multiplier: int):
        self.multiplier = multiplier
        # Se crea un lru_cache nuevo por cada instancia;
        # el ciclo de vida del caché está ligado a self.
        self.process = functools.lru_cache(maxsize=64)(self._process)

    def _process(self, value: int) -> int:
        time.sleep(0.001)
        return value * self.multiplier


# ── Demo ────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    # Fibonacci: la primera llamada rellena el caché descendente
    result = fibonacci(35)
    info = fibonacci.cache_info()
    print(f"fib(35) = {result}")
    print(f"hits={info.hits}, misses={info.misses}, size={info.currsize}")
    # hits >> misses porque la recursión reutiliza entradas anteriores

    # Verificar que el caché funciona para expensive_parse
    expensive_parse("Hello World")
    expensive_parse("Hello World")   # instantáneo: hit en caché
    print(expensive_parse.cache_info())

    # Config como NamedTuple: hashable sin esfuerzo
    cfg = Config("localhost", 5432, False)
    print(build_connection_string(cfg))
    print(build_connection_string(cfg))  # segundo llamado: hit
    print(build_connection_string.cache_info())

    # Limpiar el caché cuando la configuración cambia
    build_connection_string.cache_clear()
    print(f"después de clear: {build_connection_string.cache_info()}")

    # SafeProcessor: el caché vive en la instancia
    sp = SafeProcessor(multiplier=3)
    sp.process(10)
    sp.process(10)   # hit
    print(sp.process.cache_info())
    del sp           # caché destruido junto con la instancia

Lo que está pasando aquí

fibonacci muestra por qué lru_cache transforma algoritmos exponenciales. Sin caché, fib(35) haría ~29 millones de llamadas. Con él, hace exactamente 36 llamadas únicas — una por cada valor de n desde 0 hasta 35 — y el resto son hits. El cache_info() te lo confirma: vas a ver misses=36 y hits en el orden de decenas.

expensive_parse usa @cache porque los textos que parseas probablemente sean pocos y repetidos, y no necesitas política de desalojo. Sin maxsize, cualquier string único queda en memoria para siempre, así que es una decisión consciente de intercambiar memoria por velocidad máxima.

Config como NamedTuple resuelve el problema de argumentos compuestos de forma elegante. Una NamedTuple es una tupla con nombres, y las tuplas son hashables siempre que sus elementos también lo sean. Si intentaras pasar un dict ordinario, obtendrías TypeError: unhashable type: 'dict' en la primera llamada.

El contraste entre DataProcessor y SafeProcessor es el punto más delicado. En DataProcessor, el lru_cache está decorando el método a nivel de clase. Ese caché es un atributo de la función del módulo — es global, persiste tanto como el módulo mismo. Cada instancia que llames agrega entradas con su propio self como parte de la clave, y el caché mantiene una referencia fuerte a esa instancia. El GC no puede liberarla aunque el resto del código ya no la referencie.

SafeProcessor evita esto creando el lru_cache dentro de __init__, atado a self._process que ya tiene self capturado en el bound method. El caché resultante no incluye self como clave porque la instancia ya está ligada en el closure. Y lo más importante: cuando del sp libera la última referencia a la instancia, el caché desaparece con ella.

cache_clear() merece mención especial: úsalo cuando sepas que el estado subyacente cambió y el caché quedó obsoleto, o antes de procesar un lote grande con argumentos nuevos que desplazarían entradas útiles del LRU.

Errores que debes conocer

Error: pasar un argumento mutable como lista o dict hace que el caché falle al intentar construir la clave.

@functools.lru_cache(maxsize=128)
def process_items(items: list) -> int:
    return sum(items)

# ❌ Wrong
process_items([1, 2, 3])   # TypeError: unhashable type: 'list'

# ✅ Right
process_items(tuple([1, 2, 3]))   # convierte a tupla antes de llamar

Convertir a tuple en el punto de llamada es la solución más limpia; alternativamente puedes envolver en una función pública que haga la conversión y delegue a la función cacheada.


Error: decorar un método de instancia directamente en la clase body crea un caché global que retiene instancias indefinidamente.

# ❌ Wrong: caché global retiene self → memory leak
class Analyzer:
    @functools.lru_cache(maxsize=128)
    def analyze(self, data: str) -> dict:
        ...

# ✅ Right: caché por instancia, ciclo de vida correcto
class Analyzer:
    def __init__(self):
        self.analyze = functools.lru_cache(maxsize=128)(self._analyze)

    def _analyze(self, data: str) -> dict:
        ...

Al mover la creación del caché a __init__, cada instancia tiene su propio caché y el GC puede liberar todo cuando la instancia ya no se usa.


Error: asumir que @cache es siempre mejor que @lru_cache por ser más simple.

# ❌ Wrong en un servidor de larga vida con inputs variables
@functools.cache
def render_template(user_id: int, template_name: str) -> str:
    ...
# Con miles de usuarios únicos, la memoria crece sin límite

# ✅ Right: pon un techo explícito
@functools.lru_cache(maxsize=512)
def render_template(user_id: int, template_name: str) -> str:
    ...

@cache es correcto para espacios de argumentos pequeños y acotados; @lru_cache con maxsize explícito es la opción defensiva para producción.

115

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio