Decoradores y context managers en Python: patrones avanzados
Los decoradores son funciones que envuelven otras funciones para añadirles comportamiento sin modificar su código. Los context managers gestionan recursos (ficheros, conexiones, locks) garantizando su limpieza al salir de un bloque with.
1. Decoradores básicos
import functools
# Decorador simple
def registrar(func):
@functools.wraps(func) # Preserva __name__, __doc__, etc.
def wrapper(*args, **kwargs):
print(f"Llamando a {func.__name__}({args}, {kwargs})")
resultado = func(*args, **kwargs)
print(f"{func.__name__} devolvió: {resultado}")
return resultado
return wrapper
@registrar
def sumar(a, b):
return a + b
sumar(3, 4)
# Llamando a sumar((3, 4), {})
# sumar devolvió: 7
¿Por qué functools.wraps? Sin él, sumar.__name__ sería "wrapper", rompiendo logging, documentación y reflexión.
2. Decoradores con parámetros (fábrica de decoradores)
import functools
import time
def reintentar(max_intentos=3, espera=1.0):
"""Decorador que reintenta una función si lanza una excepción."""
def decorador(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for intento in range(1, max_intentos + 1):
try:
return func(*args, **kwargs)
except Exception as e:
if intento == max_intentos:
raise
print(f"Intento {intento} fallido: {e}. Reintentando en {espera}s...")
time.sleep(espera)
return wrapper
return decorador
@reintentar(max_intentos=3, espera=0.5)
def llamada_api_inestable():
import random
if random.random() < 0.7:
raise ConnectionError("Timeout")
return "Éxito"
resultado = llamada_api_inestable()
3. Decoradores de temporización y caché
import functools
import time
# Medir tiempo de ejecución
def cronometrar(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
t0 = time.perf_counter()
resultado = func(*args, **kwargs)
t1 = time.perf_counter()
print(f"{func.__name__} tardó {t1 - t0:.4f} s")
return resultado
return wrapper
@cronometrar
def calcular(n):
return sum(range(n))
calcular(1_000_000)
# Memoización con functools.lru_cache
from functools import lru_cache
@lru_cache(maxsize=128)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(50)) # Rápido gracias al caché
# cache (Python 3.9+): sin límite de tamaño
from functools import cache
@cache
def factorial(n):
return 1 if n == 0 else n * factorial(n - 1)
4. Decoradores de clase
import functools
# Decorador aplicado a una clase entera
def singleton(cls):
"""Garantiza que solo exista una instancia de la clase."""
instancias = {}
@functools.wraps(cls)
def obtener_instancia(*args, **kwargs):
if cls not in instancias:
instancias[cls] = cls(*args, **kwargs)
return instancias[cls]
return obtener_instancia
@singleton
class Configuracion:
def __init__(self):
self.debug = False
self.puerto = 8000
c1 = Configuracion()
c2 = Configuracion()
print(c1 is c2) # True — misma instancia
# @property, @classmethod, @staticmethod
class Temperatura:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, valor):
if valor < -273.15:
raise ValueError("Temperatura por debajo del cero absoluto")
self._celsius = valor
@property
def fahrenheit(self):
return self._celsius * 9/5 + 32
@classmethod
def desde_fahrenheit(cls, f):
return cls((f - 32) * 5/9)
@staticmethod
def es_valida(c):
return c >= -273.15
t = Temperatura(100)
print(t.fahrenheit) # 212.0
t2 = Temperatura.desde_fahrenheit(32)
print(t2.celsius) # 0.0
5. Decoradores como clases
import functools
class Validar:
"""Decorador que valida que los argumentos sean positivos."""
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
def __call__(self, *args, **kwargs):
for arg in args:
if isinstance(arg, (int, float)) and arg < 0:
raise ValueError(f"Argumento negativo: {arg}")
return self.func(*args, **kwargs)
@Validar
def raiz_cuadrada(n):
return n ** 0.5
print(raiz_cuadrada(9)) # 3.0
raiz_cuadrada(-1) # ValueError
6. Context managers con contextlib
import contextlib
import time
# Forma 1: con generator function
@contextlib.contextmanager
def cronometro(nombre="bloque"):
t0 = time.perf_counter()
try:
yield
finally:
t1 = time.perf_counter()
print(f"[{nombre}] {t1 - t0:.4f} s")
with cronometro("mi operación"):
time.sleep(0.1)
# Forma 2: con generator que pasa un valor al bloque with
@contextlib.contextmanager
def archivo_temporal(sufijo=".tmp"):
import tempfile
from pathlib import Path
f = tempfile.NamedTemporaryFile(suffix=sufijo, delete=False)
ruta = Path(f.name)
f.close()
try:
yield ruta
finally:
if ruta.exists():
ruta.unlink()
print(f"Archivo temporal eliminado: {ruta.name}")
with archivo_temporal(".csv") as ruta:
ruta.write_text("a,b,c\n1,2,3\n")
print(f"Archivo: {ruta}")
datos = ruta.read_text()
print(datos)
# Eliminado automáticamente al salir del bloque
7. Context managers como clases
import sqlite3
class ConexionBD:
"""Context manager para conexiones SQLite con auto-commit/rollback."""
def __init__(self, ruta_bd):
self.ruta_bd = ruta_bd
self.conn = None
def __enter__(self):
self.conn = sqlite3.connect(self.ruta_bd)
self.conn.row_factory = sqlite3.Row
return self.conn
def __exit__(self, tipo_exc, valor_exc, traceback):
if tipo_exc is None:
self.conn.commit()
print("Transacción confirmada")
else:
self.conn.rollback()
print(f"Rollback por: {valor_exc}")
self.conn.close()
return False # No suprimir la excepción
# Uso
with ConexionBD(":memory:") as conn:
conn.execute("CREATE TABLE datos (id INTEGER, valor TEXT)")
conn.execute("INSERT INTO datos VALUES (1, 'hola')")
# conn.commit() y conn.close() automáticos
8. contextlib.suppress y contextlib.redirect_stdout
import contextlib
import io
# suppress: ignorar excepciones específicas
with contextlib.suppress(FileNotFoundError):
import os
os.unlink("archivo_que_no_existe.txt")
# No lanza excepción, simplemente continúa
# redirect_stdout: capturar salida estándar
salida = io.StringIO()
with contextlib.redirect_stdout(salida):
print("Este texto va al buffer")
print("Y este también")
contenido = salida.getvalue()
print(f"Capturado: {contenido!r}")
# ExitStack: múltiples context managers dinámicos
from contextlib import ExitStack
archivos = ["a.txt", "b.txt", "c.txt"]
with ExitStack() as pila:
manejadores = [pila.enter_context(open(f, "w")) for f in archivos]
for i, fh in enumerate(manejadores):
fh.write(f"Archivo {i}\n")
# Todos los archivos se cierran automáticamente
9. Combinar decoradores
import functools
import time
import logging
logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
def registrar(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
logger.info("Inicio: %s", func.__name__)
resultado = func(*args, **kwargs)
logger.info("Fin: %s", func.__name__)
return resultado
return wrapper
def cronometrar(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
t0 = time.perf_counter()
resultado = func(*args, **kwargs)
logger.info("%s tardó %.4f s", func.__name__, time.perf_counter() - t0)
return resultado
return wrapper
# Los decoradores se aplican de abajo a arriba
# @registrar(cronometrar(procesar_datos(n)))
@registrar
@cronometrar
def procesar_datos(n):
return sum(range(n))
procesar_datos(1_000_000)
10. Buenas prácticas
- Siempre usa
@functools.wrapspara preservar los metadatos de la función original. - Mantén los decoradores simples: si hacen demasiado, divídelos.
- Usa
@contextlib.contextmanagerpara context managers simples; clase__enter__/__exit__para lógica compleja o estado. contextlib.suppresspara ignorar excepciones esperadas de forma explícita y legible.- Encadena context managers en una sola línea:
with open(a) as fa, open(b) as fb:. ExitStackcuando el número de context managers no se conoce en tiempo de compilación.
Conversiones relacionadas
Conversiones frecuentes del catálogo: