Python 装饰器核心知识点:无参装饰器构建、带参装饰器扩展及函数与类实现差异

引言

熟悉web开发的同学,应该了解或已经熟悉装饰器,在我们使用常使用的Flask或者Fast API框架中,注册路由的实现就是使用了对应的装饰器,如下使用Flask举例:

python 复制代码
from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello_world():
    return 'Hello, World!'

装饰器可以实现web站点的路由注册,并且这种风格API看起来居然很自然,很符合我们的直觉和逻辑,当然啦,装饰器不仅仅可以实现路由的注册,还可以实现日志记录、性能计时、权限校验等等

当然,如何使用装饰器需要结合具体的业务,及对装饰器有更深次的理解

基础知识

介绍

装饰器 实际上是一种通过包装目标函数来修改改变其行为的特殊的高阶函数,大部分的装饰器可以使用函数中的闭包来实现

无参装饰器:装饰器的 "基础形态" 与构建逻辑

无参装饰器是装饰器的最简形式 ------ 无需接收额外参数,仅对目标函数进行固定逻辑的增强。其核心依赖 Python 的闭包特性(嵌套函数对外部变量的引用),通过 "函数嵌套 + 闭包" 实现对目标函数的包装

核心原理

无参装饰器的本质是 "一个返回函数的函数",其结构遵循三步逻辑:​

  1. 定义外层函数:接收目标函数作为参数(这是装饰器与目标函数建立关联的关键)
  2. 定义内层函数:在该函数中调用目标函数,并添加额外增强逻辑(如日志、计时等)
  3. 返回内层函数:外层函数将内层函数作为结果返回,替代原目标函数

如下举例

打印函数耗时的无参装饰器:

python 复制代码
import time


def timer(func):
    """打印func耗时的装饰器"""

    def decorated(*args, **kwargs):
        st = time.perf_counter()
        ret = func(*args, **kwargs)
        print(f'当前函数耗时{time.perf_counter() - st:.2f} s')
        return ret

    return decorated

在上述示例中,timer装饰器接收待装饰函数func作为唯一位置参数,并在函数内定义并返回一个新函数:decorated

在写装饰器时,我们一般把decorated叫做"包装函数",包装函数通常接收任意数目的可变参数(*args, **kwargs),主要是通过调用原始函数func来完成工作,在包装函数内部,通常会添加一些额外步骤,增强原始函数的行为,比如:打印信息、修改参数等等

当其他函数应用了timer装饰器后,包装函数decorated会作为装饰器的返回值,完全替代原始函数func:

python 复制代码
import random


@timer
def random_sleep():
    time.sleep(random.random())

实际装饰器逻辑等同于random_sleep=timer(random_sleep),这样的逻辑

关键注意点

  • 必须保留原函数元信息:若不使用@wraps(func),wrapper函数会覆盖原函数的__name__、__doc__等属性,导致调试时无法识别原函数(如add.__name__会变成wrapper)
  • 支持任意参数的目标函数:内层函数使用*args和**kwargs接收参数,确保装饰器可适配不同参数签名的函数
  • 不能动态调整增强逻辑:无参装饰器的增强逻辑固定(如上述日志格式无法修改),若需动态配置,需升级为带参装饰器

带参装饰器:装饰器的 "灵活扩展" 与实现思路

当需要动态调整装饰器的增强逻辑时(如日志装饰器需指定日志级别、权限装饰器需指定允许的角色),无参装饰器便无法满足需求。此时需通过 "三层嵌套" 实现带参装饰器,其核心是在外层多增加一层 "参数接收层"。

带参装饰器的核心原理

带参装饰器的本质是 "一个返回装饰器的函数",比无参装饰器多一层嵌套,结构逻辑如下:

  • 最外层函数:接收装饰器的参数(如日志级别、角色列表),并返回内层的 "装饰器函数"
  • 中间层函数:接收目标函数,作为无参装饰器的外层函数使用
  • 最内层函数:调用目标函数,结合最外层的参数实现动态增强逻辑

举例如下

那我们修改上述的timer装饰器,新增是否打印方法名和参数的字段:

python 复制代码
def timer(print_args=False):
    """
    装饰器:打印函数耗时
    :param print_args:是否打印方法名及参数,默认为False
    """

    def decorator(func):
        def wrapper(*args, **kwargs):
            st = time.perf_counter()
            ret = func(*args, **kwargs)
            print(f'当前函数耗时{time.perf_counter() - st:.2f} s')
            if print_args:
                print(f'方法名:{func.__name__}({args}, {kwargs})')
            return ret

        return wrapper

    return decorator

可以看到,为了增加对参数的支持,装饰器在原本两层的嵌套函数下,又增加了一层,这是由于整个装饰的过程发生了变化,装饰器展开后等同于:

python 复制代码
_decorator = timer(print_args=True) 
random_sleep = _decorator(random_sleep) 

先进行第一次调用,传入装饰器参数,获取第一层嵌套的函数decorator 然后进行第二次调用,获取第二层嵌套函数wrapper

在应用有参数有参数装饰器时,一共要做两次函数调用,所以装饰函数需要包含三层嵌套函数,正式因为如此,有参数的装饰器代码一直难写、难读,但是不要紧,我们会在后面梳理如何用类实现有参数的装饰器,减少代码的嵌套层级

使用functools.wraps()修饰包装函数

在上面我们提到了保留原函数元信息 ,在不借助外部方法的支持下,通常会丢失原函数的元信息,这是装饰器带来的副作用,如下: 我们在上面使用timer装饰random_sleep函数后,现在假如我们需要读取random_sleep函数的名词、文档等属性,就会碰到尴尬的事情,函数的所有元数据变成了装饰器内层包装函数的信息啦 对于装饰器来说,上述元数据的丢失只能算是一个常见的小问题,但是当我们在装饰器上做一些复杂的事情时,比如给原始函数增加格外属性或函数等,那么就会踏入一个新的陷阱

举例:现在有一个装饰器calls_counter,专门记录当前函数被调用了多少次,并且提供了一个格外函数打印总次数:

python 复制代码
def calls_counter(func):
    """
    装饰器:记录当前函数被调用多少次,
    :param func: 使用func.print_counter() 可以打印一共调用了多少次
    :return:
    """
    counter = 0

    def decorated(*args, **kwargs):
        nonlocal counter
        counter += 1
        return func(*args, **kwargs)

    def print_counter():
        print(f'counter次数:{counter}次')

    decorated.print_counter = print_counter
    return decorated

装饰器执行效果如下: 在单独使用calls_counter时,程序是正常工作的,但是如果结合我们之前定义的timer装饰器一起使用时,就是出现问题: 虽然,timer装饰器仍可正常使用,函数也会打印耗时信息,但是本应该由装饰器calls_counter追加给函数的print_counter属性找不到啦!!!

分析原因:我们首先拆解上面的装饰器调用如下:

python 复制代码
random_sleep=calls_counter(random_sleep)
random_sleep=timer(random_sleep)

首先,有calls_counter对函数进行包装,此时的random_sleep变成了新的包装函数,包含print_counter属性 然后,使用timer函数包装后,random_sleep变成timer提供的包装函数,原来函数额外的print_counter属性,就会被自然的抛弃掉了

要解决上述问题,我们就需要在装饰器内包装函数时,保留原函数的额外属性,当然,也会解决保留原函数的元数据的问题,而functools模块下面的wraps()函数正好可以完成这件事,如下使用:

python 复制代码
import time
from functools import wraps


def timer(func):
    """打印func耗时的装饰器"""

    @wraps(func)
    def decorated(*args, **kwargs):
        st = time.perf_counter()
        ret = func(*args, **kwargs)
        print(f'当前函数耗时{time.perf_counter() - st:.2f} s')
        return ret

    return decorated

def calls_counter(func):
    """
    装饰器:记录当前函数被调用多少次,
    :param func: 使用func.print_counter() 可以打印一共调用了多少次
    :return:
    """
    counter = 0

    @wraps(func)
    def decorated(*args, **kwargs):
        nonlocal counter
        counter += 1
        return func(*args, **kwargs)

    def print_counter():
        print(f'counter次数:{counter}次')

    decorated.print_counter = print_counter
    return decorated

运行结果如下: 添加@wraps(func)来装饰decorated函数后,wraps首先会基于原函数func来更新包装函数decorated的名称、文档等元数据,之后再将func上的额外属性添加到decorated函数上

正因如此,在编写装饰器时,建议使用@functools.wraps()来修饰包装函数,以免出现一些意料之外的问题

实现可选参数装饰器

假如我们用嵌套函数的形式实现装饰器,接收参数和不接受参数的装饰器实现方式有这较大区别,前者会比后者多一层嵌套 当我们实现一个接收参数的装饰后,即便所有的参数都有默认值可选参数,那么我们在使用装饰器时也一定是需要加上括号的,如下:

python 复制代码
@delay_start(duration=2)

@delay_start()

有参数装饰器的这个特点提高了它的使用成本,如果使用者忘记添加这对括号时,程序就会出错!!

那我们有没有什么办法,能让我们在使用时省去这对括号呢,直接使用@delay_start这种写法呢?答案当然是可以的,利用仅限关键字参数,我们可以很方便的做到这一点,如下,定义可选参数duration的delay_start装饰器:

python 复制代码
import time


def delay_start(func=None, *, duration=1):
    """
    在执行别装饰函数前,等待一段时间
    :param func: 被装饰函数
    :param duration:需要等待秒数
    """

    def decorator(_func):
        def wrapper(*args, **kwargs):
            print(f"等待{duration}秒后开始执行")
            time.sleep(duration)
            return _func(*args, **kwargs)

        return wrapper

    if func is None:
        return decorator
    else:
        return decorator(func)

将所有参数都变成提供了默认值的可选参数

当func为None时,代表使用方提供了关键字参数,比如@delay_start(duration=3),此时返回的接收单个函数参数的内层子装饰器decorator

当位置参数func不为None时,代表使用方没有提供关键字,直接使用无括号@delay_start调用方式,此时返回内层包装函数wrapper 这样的话我们就可以使用多种方式调用啦:

python 复制代码
#1. 不提供任何参数
@delayed_start
def hello(): ...

# 2. 提供可选的关键字参数
@delayed_start(duration=2)
def hello(): ...

# 3. 提供括号调用,但不提供任何参数
@delayed_start()
def hello(): ...

使用类来实现装饰器

在绝大多数的情况下,我们都是使用嵌套函数来实现,但是并非时构造装饰器的唯一方式,事实上,某个对象是否能通过装饰器(@decorator)的形式使用只有一条判断标准,那就是decorator是不是一个可调用对象

函数自然是可调用的对象,除此之外,类也是可调用对象 使用callable()内置函数可以判断某个对象是否可调用 如果一个类实现了__call__ 魔术方法,那么它的实例也会变成可调用对象: 类中存在__call__魔术方法时,可以像调用普通函数一样提供额外参数来使用,基于类的这些特点,我们就可以使用它来实现装饰器 如果按照装饰器用于替换原函数的对象类型来分类,类实现的装饰器分为两种,一种是函数替换,一种是实例替换,下面我们一一介绍一下:

函数替换

函数替换装饰器虽然基于类实现的,但是用来替换原函数的对象仍然是个普通函数,这种技术最适合用来实现接收参数的装饰器,使用类实现了接收参数的timer装饰器:

python 复制代码
from functools import wraps
import time


class timer:
    """
    打印函数耗时
    """

    def __init__(self, print_args):
        self.print_args = print_args

    def __call__(self, func):
        @wraps(func)
        def decorated(*args, **kwargs):
            st = time.perf_counter()
            ret = func(*args, **kwargs)
            if self.print_args:
                print(f'{func.__name__} takes {time.perf_counter() - st}s')
            print(f'运行时间为:{time.perf_counter() - st}s')
            return ret

        return decorated

通过类实现的装饰器,其实就是把原本的两次函数替换成了类和类实例的调用:

  • 第一次调用:_deco=timer(print_args=True)实际上就是初始化了一个实例
  • 第二次调用:func=_deco(func)是在调用timer实例,触发__call__方法

相比于三次嵌套的闭包函数装饰,上面这种类的写法在实现有参数装饰器时,代码更加清晰,不过虽然装饰器是使用类实现的。但是最终用来替换原函数的对象, 仍然是一个__call__方法里的闭包函数decorated

虽然函数替换装饰的代码更加简单,但是这个和普通装饰器并没有本质上区别,下面我们来介绍一种更加强大的装饰器,用实例来替换原函数的实例替换装饰器

实例替换

和"函数替换"装饰器不一样,​"实例替换"装饰器最终会用一个类实例来替换原函数。通过组合不同的工具,它既能实现无参数装饰器,也能实现有参数装饰器

实现无参数装饰器

用类来实现装饰器时,被装饰的函数func会作为唯一的初始化参数传递到类的实例化方法__init__中。同时,类的实例化结果------类实例(class instance)​,会作为包装对象替换原始函数 如下实现实例替换版本的无参装饰器DelayedStart

python 复制代码
from functools import update_wrapper


class DelayedStart:
    """在执行被装饰函数前,等待 1 秒钟"""

    def __init__(self, func):
        update_wrapper(self, func)
        self.func = func

    def __call__(self, *args, **kwargs):
        print(f'Wait for 1 second before starting...')
        time.sleep(1)
        return self.func(*args, **kwargs)

    def eager_call(self, *args, **kwargs):
        """跳过等待,立刻执行被装饰函数"""
        print('Call without delay')
        return self.func(*args, **kwargs)
  1. update_wrapper与前面的wraps一样,都是把被包装函数的元数据更新到包装者(在这里是DelayedStart实例)上
  2. 通过实现__call__方法,让DelayedStart的实例变得可调用,以此模拟函数的调用行为
  3. 为装饰器类定义额外方法,提供更多样化的接口

执行效果如下:

  1. 被装饰的hello函数已经变成了装饰器类DelayedStart的实例,但是因为update_wrapper的作用,这个实例仍然保留了被装饰函数的元数据
  2. hello():此时触发的其实是装饰器类实例的__call__方法
  3. hello.eager_call():使用额外的eager_call接口调用函数
实现有参数装饰器

同普通装饰器一样,​"实例替换"装饰器也可以支持参数。为此我们需要先修改类的实例化方法,增加额外的参数,再定义一个新函数,由它来负责基于类创建新的可调用对象,这个新函数同时也是会被实际使用的装饰器

python 复制代码
import functools


class DelayedStart:
    """在执行被装饰函数前,等待一段时间
    :param func: 被装饰的函数
    :param duration: 需要等待的秒数
    """

    def __init__(self, func, *, duration=1):
        update_wrapper(self, func)
        self.func = func
        self.duration = duration

    def __call__(self, *args, **kwargs):
        print(f'Wait for {self.duration} second before starting...')
        time.sleep(self.duration)
        return self.func(*args, **kwargs)

    def eager_call(self, *args, **kwargs): ...


def delayed_start(**kwargs):
    """装饰器:推迟某个函数的执行"""
    return functools.partial(DelayedStart, **kwargs) 
  1. 把func参数以外的其他参数都定义为"仅限关键字参数",从而更好地区分原始函数与装饰器的其他参数
  2. 通过partial构建一个新的可调用对象,这个对象接收的唯一参数是待装饰函数func,因此可以用作装饰器 运行如下:
相关推荐
数据智能老司机6 小时前
精通 Python 设计模式——分布式系统模式
python·设计模式·架构
数据智能老司机7 小时前
精通 Python 设计模式——并发与异步模式
python·设计模式·编程语言
数据智能老司机7 小时前
精通 Python 设计模式——测试模式
python·设计模式·架构
数据智能老司机7 小时前
精通 Python 设计模式——性能模式
python·设计模式·架构
c8i7 小时前
drf初步梳理
python·django
每日AI新事件7 小时前
python的异步函数
python
这里有鱼汤9 小时前
miniQMT下载历史行情数据太慢怎么办?一招提速10倍!
前端·python
databook18 小时前
Manim实现脉冲闪烁特效
后端·python·动效
程序设计实验室18 小时前
2025年了,在 Django 之外,Python Web 框架还能怎么选?
python
倔强青铜三20 小时前
苦练Python第46天:文件写入与上下文管理器
人工智能·python·面试