Saltar al contenido
decoradores.py · devschool

Decoradores en Python

Lección 16 de 31 · 11 min de lectura · Actualizado el

En esta lección
  1. Lo que necesitas saber antes
  2. Tu primer decorador
  3. Funciones con argumentos y valor de retorno
  4. functools.wraps: conservar la identidad de la función
  5. Decoradores útiles
  6. Decoradores con parámetros
  7. Varios decoradores a la vez
  8. Decoradores que ya trae Python
  9. Errores frecuentes
  10. Resumen

Un decorador es una forma de añadir comportamiento a una función sin tocar su código: medir cuánto tarda, registrar cada llamada, comprobar permisos, guardar resultados en caché… Se escriben con una @ encima del def y los verás por todas partes: en las clases (@property), en frameworks web como Flask (@app.route) y en las pruebas (@pytest.fixture). En esta lección vas a entender cómo funcionan por dentro y a escribir los tuyos, desde el más simple hasta decoradores que reciben parámetros.

Lo que necesitas saber antes

Los decoradores se apoyan en dos ideas que viste en funciones avanzadas. Repasémoslas rápido.

Las funciones son objetos. Puedes guardarlas en variables, pasarlas como argumento y devolverlas desde otra función:

def gritar(texto):
    return texto.upper() + "!"

hablar = gritar                    # sin paréntesis: la función, no su resultado
print(hablar("hola"))              # HOLA!

def aplicar_dos_veces(funcion, valor):
    return funcion(funcion(valor))

print(aplicar_dos_veces(gritar, "ey"))   # EY!!

Las funciones internas recuerdan su entorno (closures). Una función definida dentro de otra puede usar las variables de la exterior incluso después de que esta haya terminado:

def crear_saludo(saludo):
    def saludar(nombre):
        return f"{saludo}, {nombre}"
    return saludar

buenos_dias = crear_saludo("Buenos días")
print(buenos_dias("Carmen"))   # Buenos días, Carmen

Con esas dos piezas ya puedes construir un decorador.

Tu primer decorador

Un decorador es una función que recibe una función y devuelve otra función (normalmente una versión “envuelta” de la original). Vamos a crear uno que avise antes y después de ejecutar la función:

def anunciar(funcion):
    def envoltura():
        print(f"-> Empieza {funcion.__name__}")
        funcion()
        print(f"<- Termina {funcion.__name__}")
    return envoltura

def regar_plantas():
    print("Regando las plantas...")

regar_plantas = anunciar(regar_plantas)
regar_plantas()
# -> Empieza regar_plantas
# Regando las plantas...
# <- Termina regar_plantas

Fíjate en la línea regar_plantas = anunciar(regar_plantas). Le pasamos la función original a anunciar, que devuelve envoltura, y guardamos esa envoltura con el mismo nombre. A partir de ahí, quien llame a regar_plantas() está llamando en realidad a envoltura(), que a su vez llama a la original. funcion.__name__ es un atributo que guarda el nombre de cualquier función.

La sintaxis @

Escribir el nombre de la función tres veces es incómodo. Python ofrece una forma abreviada: poner @decorador justo encima del def.

@anunciar
def sacar_basura():
    print("Sacando la basura...")

sacar_basura()
# -> Empieza sacar_basura
# Sacando la basura...
# <- Termina sacar_basura

@anunciar encima de def sacar_basura es exactamente lo mismo que escribir sacar_basura = anunciar(sacar_basura) después del def. No hay ninguna magia más.

Nota: el decorador se ejecuta una sola vez, al definir la función, no en cada llamada. Lo que se ejecuta en cada llamada es la envoltura que devuelve.

Funciones con argumentos y valor de retorno

Nuestro decorador tiene dos problemas. Si la función decorada recibe parámetros, falla, porque envoltura() no acepta ninguno. Y si la función devuelve algo, se pierde, porque envoltura no tiene return. La solución es usar *args y **kwargs para aceptar cualquier combinación de argumentos, y devolver el resultado:

def anunciar(funcion):
    def envoltura(*args, **kwargs):
        print(f"-> {funcion.__name__} con {args} {kwargs}")
        resultado = funcion(*args, **kwargs)
        print(f"<- {funcion.__name__} devuelve {resultado!r}")
        return resultado
    return envoltura

@anunciar
def precio_final(precio, descuento=0):
    return round(precio * (1 - descuento / 100), 2)

total = precio_final(80, descuento=15)
print(total)
# -> precio_final con (80,) {'descuento': 15}
# <- precio_final devuelve 68.0
# 68.0

Esta es la plantilla de casi cualquier decorador:

def mi_decorador(funcion):
    def envoltura(*args, **kwargs):
        # algo antes
        resultado = funcion(*args, **kwargs)
        # algo después
        return resultado
    return envoltura

functools.wraps: conservar la identidad de la función

Hay un efecto secundario molesto. Al sustituir la función por la envoltura, se pierde su nombre y su documentación:

@anunciar
def calcular_iva(base):
    """Devuelve el IVA general (21 %) de una base imponible."""
    return round(base * 0.21, 2)

print(calcular_iva.__name__)   # envoltura
print(calcular_iva.__doc__)    # None

Eso confunde a las herramientas de depuración, a help() y a los mensajes de error. El decorador @wraps del módulo functools copia el nombre, la documentación y otros datos de la función original a la envoltura:

from functools import wraps

def anunciar(funcion):
    @wraps(funcion)
    def envoltura(*args, **kwargs):
        print(f"-> {funcion.__name__}")
        return funcion(*args, **kwargs)
    return envoltura

@anunciar
def calcular_iva(base):
    """Devuelve el IVA general (21 %) de una base imponible."""
    return round(base * 0.21, 2)

print(calcular_iva(100))
print(calcular_iva.__name__)
print(calcular_iva.__doc__)
# -> calcular_iva
# 21.0
# calcular_iva
# Devuelve el IVA general (21 %) de una base imponible.

Consejo: pon siempre @wraps(funcion) en tus decoradores. Es una línea y te ahorra problemas difíciles de entender más adelante.

Decoradores útiles

Veamos decoradores que podrías usar en un proyecto real.

Medir el tiempo

import time

def cronometrar(funcion):
    @wraps(funcion)
    def envoltura(*args, **kwargs):
        inicio = time.perf_counter()
        resultado = funcion(*args, **kwargs)
        duracion = time.perf_counter() - inicio
        print(f"[{funcion.__name__}] {duracion:.3f} s")
        return resultado
    return envoltura

@cronometrar
def generar_informe(n):
    total = sum(i * i for i in range(n * 1000))   # un cálculo que tarda un poco
    return f"Informe con {n} filas"

print(generar_informe(500))
# [generar_informe] 0.029 s   (el tiempo varía en cada equipo)
# Informe con 500 filas

time.perf_counter() es un reloj de alta precisión pensado para medir duraciones.

Contar las llamadas

Una función también es un objeto, así que puedes guardarle atributos. Lo aprovechamos para llevar la cuenta:

def contar_llamadas(funcion):
    @wraps(funcion)
    def envoltura(*args, **kwargs):
        envoltura.llamadas += 1
        return funcion(*args, **kwargs)
    envoltura.llamadas = 0
    return envoltura

@contar_llamadas
def enviar_aviso(socio):
    return f"Aviso enviado a {socio}"

for socio in ["Ana", "Iker", "Nerea"]:
    enviar_aviso(socio)
print(enviar_aviso.llamadas)   # 3

Guardar resultados (caché casera)

Si una función tarda y la llamas muchas veces con los mismos argumentos, puedes guardar cada resultado en un diccionario:

def memorizar(funcion):
    guardados = {}

    @wraps(funcion)
    def envoltura(*args):
        if args not in guardados:
            guardados[args] = funcion(*args)
        else:
            print(f"  (desde la caché: {args})")
        return guardados[args]
    return envoltura

@memorizar
def coste_envio(provincia, kilos):
    print(f"  calculando envío a {provincia}...")
    return 4.5 + kilos * 0.8

print(coste_envio("Cantabria", 3))
print(coste_envio("Cantabria", 3))
#   calculando envío a Cantabria...
# 6.9
#   (desde la caché: ('Cantabria', 3))
# 6.9

El diccionario guardados vive en el closure: cada función decorada tiene el suyo y persiste entre llamadas. Las tuplas de argumentos sirven como clave porque son inmutables. En la práctica no hace falta escribirlo: Python ya trae @cache, que verás enseguida.

Validar argumentos

Un decorador puede comprobar los datos antes de dejar pasar la llamada y lanzar una excepción si algo no cuadra:

def solo_positivos(funcion):
    @wraps(funcion)
    def envoltura(*args):
        for valor in args:
            if valor <= 0:
                raise ValueError(f"{funcion.__name__}: {valor} no es positivo")
        return funcion(*args)
    return envoltura

@solo_positivos
def area_rectangulo(base, altura):
    return base * altura

print(area_rectangulo(4, 2.5))   # 10.0
try:
    area_rectangulo(4, -1)
except ValueError as error:
    print("Error:", error)
# Error: area_rectangulo: -1 no es positivo

La ventaja es que la validación queda separada de la lógica: area_rectangulo solo calcula, y el mismo decorador sirve para muchas otras funciones.

Decoradores con parámetros

¿Y si quieres configurar el decorador, por ejemplo indicar cuántas veces reintentar una operación? Algo como @reintentar(veces=3). Aquí reintentar(veces=3) se ejecuta primero y debe devolver un decorador. Por eso hacen falta tres niveles de funciones:

def reintentar(veces):                    # 1. recibe la configuración
    def decorador(funcion):               # 2. recibe la función
        @wraps(funcion)
        def envoltura(*args, **kwargs):   # 3. se ejecuta en cada llamada
            for intento in range(1, veces + 1):
                try:
                    return funcion(*args, **kwargs)
                except ConnectionError as error:
                    print(f"Intento {intento} fallido: {error}")
            raise ConnectionError(f"Sin conexión tras {veces} intentos")
        return envoltura
    return decorador

respuestas = iter([False, False, True])    # simula una red inestable

@reintentar(veces=3)
def descargar_tiempo():
    if not next(respuestas):
        raise ConnectionError("tiempo de espera agotado")
    return "Santander: 18 °C, nubes"

print(descargar_tiempo())
# Intento 1 fallido: tiempo de espera agotado
# Intento 2 fallido: tiempo de espera agotado
# Santander: 18 °C, nubes

Léelo de fuera hacia dentro: @reintentar(veces=3) equivale a descargar_tiempo = reintentar(veces=3)(descargar_tiempo). La primera llamada fabrica el decorador con veces guardado en el closure, y la segunda decora la función.

Otro ejemplo más corto, un decorador que repite la función:

def repetir(n):
    def decorador(funcion):
        @wraps(funcion)
        def envoltura(*args, **kwargs):
            return [funcion(*args, **kwargs) for _ in range(n)]
        return envoltura
    return decorador

@repetir(3)
def tirar_dado():
    return 6        # un dado muy afortunado

print(tirar_dado())   # [6, 6, 6]

Varios decoradores a la vez

Puedes apilar decoradores. Se aplican de abajo arriba: el más cercano al def envuelve primero.

def negrita(funcion):
    @wraps(funcion)
    def envoltura(*args, **kwargs):
        return f"<b>{funcion(*args, **kwargs)}</b>"
    return envoltura

def cursiva(funcion):
    @wraps(funcion)
    def envoltura(*args, **kwargs):
        return f"<i>{funcion(*args, **kwargs)}</i>"
    return envoltura

@negrita
@cursiva
def titulo(texto):
    return texto

print(titulo("Oferta"))   # <b><i>Oferta</i></b>

Equivale a titulo = negrita(cursiva(titulo)). Si cambias el orden, cambia el resultado: con @cursiva arriba obtendrías <i><b>Oferta</b></i>.

Decoradores que ya trae Python

No siempre tendrás que escribir los tuyos. Estos son los que más usarás:

DecoradorDóndePara qué
@propertyClasesConvertir un método en un atributo calculado o validado
@staticmethodClasesMétodo que no recibe ni el objeto ni la clase
@classmethodClasesMétodo que recibe la clase (cls); constructores alternativos
@dataclassClasesGenerar __init__, __repr__ y __eq__ automáticamente
@functools.cacheFuncionesMemorizar resultados (como nuestro memorizar, pero mejor)
@functools.wrapsDecoradoresConservar nombre y documentación de la función envuelta
@functools.total_orderingClasesCompletar las comparaciones <=, >, >=

Los de clases los tienes explicados en Clases y objetos y en métodos especiales. @cache es la versión profesional de nuestra caché casera:

from functools import cache

@cache
def dias_del_mes(mes, anio):
    print(f"  calculando {mes}/{anio}")
    if mes == 2:
        return 29 if anio % 4 == 0 and (anio % 100 != 0 or anio % 400 == 0) else 28
    return 30 if mes in (4, 6, 9, 11) else 31

print(dias_del_mes(2, 2028), dias_del_mes(2, 2028))
#   calculando 2/2028
# 29 29

La segunda llamada no vuelve a calcular nada. Lo viste en acción con Fibonacci en recursividad.

Nota: los decoradores también se pueden aplicar a clases, como hace @dataclass: reciben la clase, la modifican o la sustituyen, y la devuelven. La idea es la misma.

Errores frecuentes

  • Llamar a la función al decorar: escribir return envoltura() en lugar de return envoltura. El decorador debe devolver la función, no ejecutarla.
  • Olvidar el return dentro de la envoltura: la función decorada devuelve siempre None, aunque la original devolviera un valor.
  • No usar *args, **kwargs: la envoltura solo funciona con funciones sin parámetros, y falla con TypeError: anunciar.<locals>.envoltura() takes 0 positional arguments but 1 was given.
  • Olvidar @wraps: la función pierde su nombre y su docstring, y en los errores aparece envoltura por todas partes.
  • Confundir @decorador con @decorador(): si el decorador no tiene parámetros, no lleves paréntesis. Si los tiene, como @repetir(3), son obligatorios. Si escribes @repetir sin paréntesis, al llamar a la función obtienes TypeError: repetir.<locals>.decorador() missing 1 required positional argument: 'funcion'.
  • Pensar que el decorador se ejecuta en cada llamada: el cuerpo del decorador se ejecuta una vez, al definir la función; solo la envoltura se ejecuta en cada llamada.

Resumen

ConceptoQué esEjemplo
DecoradorFunción que recibe una función y devuelve otradef anunciar(funcion): ...
@decoradorAtajo de f = decorador(f)@anunciar encima del def
EnvolturaFunción interna que añade el comportamientodef envoltura(*args, **kwargs)
*args, **kwargsAceptar cualquier argumentofuncion(*args, **kwargs)
@wraps(funcion)Conservar nombre y docstringfrom functools import wraps
Con parámetrosTres niveles de funciones@reintentar(veces=3)
ApilarSe aplican de abajo arriba@negrita sobre @cursiva
De la bibliotecaYa hechos@property, @cache, @dataclass

En la siguiente lección aprenderás a capturar y lanzar errores con excepciones.

Pon a prueba lo que has aprendido

[Python] ¿Qué imprime este código?
@negrita
@cursiva
def titulo(texto):
    return texto

print(titulo("Oferta"))

[Python] El decorador deco devuelve una envoltura SIN usar functools.wraps. ¿Qué imprime saludar.__name__ y saludar.__doc__ para una función decorada con él?
def deco(funcion):
    def envoltura(*args, **kwargs):
        return funcion(*args, **kwargs)
    return envoltura

@deco
def saludar():
    "Saluda."

[Python] ¿Qué imprime este programa?
def anotar(funcion):
    print("decorando", funcion.__name__)
    def envoltura(*args, **kwargs):
        print("llamada")
        return funcion(*args, **kwargs)
    return envoltura

@anotar
def g():
    pass

g()
g()

[Python] @repetir(3) lleva parámetros. ¿A qué equivale exactamente sobre una función tirar_dado?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.