除了函数,Python怎么用类实现装饰器?

何川
何川
发布时间:2026-11-06| 16人浏览过
原创

详细解析Python中如何利用类与__call__魔法方法实现装饰器。包含原理机制、无参和有参类装饰器完整代码、类方法装饰陷阱与描述符解决方案,并附有选型建议。

除了函数,Python怎么用类实现装饰器?

在编写数据抓取脚本或内部接口时,假设你需要对特定函数进行调用频次统计、耗时告警或失败重试。如果采用常规的函数嵌套闭包,状态变量往往需要借助 nonlocal 关键字或全局字典来存取,不仅结构分散,后续若要对外暴露清零计数、导出度量等接口更是捉襟见肘。本文直接回答这个问题:利用 Python 类的 __call__ 魔法方法,可以把装饰器写成面向对象的结构,使状态维护与函数拦截自然合一。

一、核心原理:类是如何作为装饰器工作的?

很多初学者容易被 @decorator 这种语法糖所困惑,以为装饰器只能是函数。实际上,Python 的装饰器机制对装饰对象的具体类型没有任何硬性要求,唯一的硬性标准是:该对象必须是可调用的(Callable)。

在 Python 中,不仅函数是可调用的,任何定义了 __call__ 魔法方法的类的实例对象同样也是可调用的。当我们对函数添加装饰器语法糖时:

@MyDecorator
def process(data):
    return data * 2

Python 解释器在编译加载该模块时,会将其完全等价地解构成一次函数重新绑定赋值:

process = MyDecorator(process)

这个等价过程清晰揭示了类装饰器的完整生命周期,它天然划分为两个阶段:

  • 定义阶段(实例化):解释器执行 MyDecorator(process),触发类的 __init__(self, func)。此时,原函数 process 被作为参数传入,类将其绑定为自身实例属性 self.func,完成初始化。原函数名 process 实际上已指向了一个 MyDecorator 类的实例。
  • 执行阶段(调用截获):业务代码后续执行 process(10) 时,由于 process 是一个实例对象,Python 自动转向调用该实例的 __call__(self, *args, **kwargs) 方法。类在这个方法中完成前后切面逻辑,并调用底层的 self.func。
除了函数,Python怎么用类实现装饰器?
图 1:类装饰器的两个核心生命周期阶段与时序分析

二、基础实践:无参数类装饰器的五个标准步骤

掌握了 __init__ 与 __call__ 的分工后,我们以一个具体的“函数调用计数器与执行审计器”为例,展示无参数类装饰器的实现过程。该类不仅能拦截调用,还能持久记录调用次数并随时提供重置接口。

除了函数,Python怎么用类实现装饰器?
图 2:构建无参数类装饰器的五个标准实现步骤

1. 完整实现代码

以下代码展示了一个功能完备的调用计数类装饰器 CallCounter:

import functools
import time

class CallCounter:
    def __init__(self, func):
        # 步骤 1: 检查可调用性并保存原函数引用
        if not callable(func):
            raise TypeError("被装饰对象必须是可调用对象")
        self.func = func
        
        # 步骤 2: 同步函数元数据(保留原函数的名称、文档与签名)
        functools.update_wrapper(self, func)
        
        # 步骤 3: 初始化实例专属的状态变量
        self.calls = 0
        self.last_called_at = None

    def __call__(self, *args, **kwargs):
        # 步骤 4: 在 __call__ 中编写拦截逻辑与状态演进
        self.calls += 1
        self.last_called_at = time.time()
        print(f"[审计日志] {self.func.__name__} 正在被调用,当前为第 {self.calls} 次调用")
        
        # 执行被包装的原函数并返回其运算结果
        result = self.func(*args, **kwargs)
        return result

    def reset(self):
        # 提供给外部运维或测试使用的状态重置接口
        self.calls = 0
        self.last_called_at = None
        print(f"[审计日志] {self.func.__name__} 的调用计数已重置为 0")

2. 装配与测试运行

在目标业务函数上直接使用 @CallCounter 进行装饰,并验证调用计数与重置特性:

@CallCounter
def calculate_tax(income, rate=0.2):
    """计算个人所得税"""
    return income * rate

# 触发业务调用
print("计算结果 1:", calculate_tax(10000))
print("计算结果 2:", calculate_tax(20000, rate=0.25))

# 检查函数元数据是否完整保留
print("函数真实名称:", calculate_tax.__name__)
print("函数文档说明:", calculate_tax.__doc__)

# 直接读取类装饰器对外暴露的实例状态与方法
print("累计调用次数:", calculate_tax.calls)
calculate_tax.reset()
print("重置后调用次数:", calculate_tax.calls)
示例输出
[审计日志] calculate_tax 正在被调用,当前为第 1 次调用
计算结果 1: 2000.0
[审计日志] calculate_tax 正在被调用,当前为第 2 次调用
计算结果 2: 5000.0
函数真实名称: calculate_tax
函数文档说明: 计算个人所得税
累计调用次数: 2
[审计日志] calculate_tax 的调用计数已重置为 0
重置后调用次数: 0
开发提醒:元信息同步必须使用 functools.update_wrapper

在常规函数装饰器中,通常使用 @functools.wraps(func) 修饰内层 wrapper。而在类装饰器中,被装饰后的目标实际上是一个类实例对象,因此需在 __init__ 中显式调用 functools.update_wrapper(self, func)。这样可以确保外部代码和调试工具访问 __name__、__doc__ 以及 inspect.signature 时不会变成装饰器类的通用说明。

三、进阶结构:带参数类装饰器的“职责翻转”

在实际开发中,装饰器常常需要接收配置参数,例如重试次数 @Retry(max_attempts=3, delay=0.5)。在类装饰器中引入配置参数时,方法的职责会发生彻底反转,这是许多开发者最容易混淆的地方。

除了函数,Python怎么用类实现装饰器?
图 3:无参类装饰器与带参类装饰器的结构与职责倒置对比

1. 为什么带参数时职责会反转?

分析带参数的语法糖展开过程:

@Retry(max_attempts=3, delay=0.5)
def send_request(url):
    ...

根据 Python 的语法解析规则,它等价于两步操作:

# 第一步:先执行 Retry(max_attempts=3, delay=0.5),生成装饰器实例
decorator_instance = Retry(max_attempts=3, delay=0.5)

# 第二步:将原函数作为入参传给该实例进行调用
send_request = decorator_instance(send_request)

由此可见:

  • __init__ 的入参不再是原函数,而是装饰器的配置参数(如 max_attempts、delay);
  • __call__ 的入参变成了被装饰的函数 func,而且 __call__ 必须返回一个真正的包装函数。

2. 带参类装饰器完整示例:失败自动重试器

下面是一个工业级实用的自动重试装饰器类:

import time
import functools

class Retry:
    def __init__(self, max_attempts=3, delay=0.2, exceptions=(Exception,)):
        # 负责保存装饰器本身的配置参数
        self.max_attempts = max_attempts
        self.delay = delay
        self.exceptions = exceptions

    def __call__(self, func):
        # 负责接收被装饰函数,并构建返回闭包 wrapper
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_exception = None
            for attempt in range(1, self.max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except self.exceptions as exc:
                    last_exception = exc
                    print(f"[警告] 执行 {func.__name__} 失败 (第 {attempt}/{self.max_attempts} 次): {exc}")
                    if attempt < self.max_attempts:
                        time.sleep(self.delay)
            # 达到最大重试次数仍然失败,向上抛出异常
            raise last_exception
        return wrapper

验证该重试类装饰器对网络请求或不稳定操作的容错效果:

# 模拟一个偶发异常的外部接口
mock_state = {"counter": 0}

@Retry(max_attempts=3, delay=0.1, exceptions=(ConnectionError,))
def fetch_user_profile(user_id):
    mock_state["counter"] += 1
    if mock_state["counter"] < 3:
        raise ConnectionError("远程服务响应超时")
    return {"user_id": user_id, "status": "active"}

# 运行验证
user = fetch_user_profile(1001)
print("最终成功获取数据:", user)
示例输出
[警告] 执行 fetch_user_profile 失败 (第 1/3 次): 远程服务响应超时
[警告] 执行 fetch_user_profile 失败 (第 2/3 次): 远程服务响应超时
最终成功获取数据: {'user_id': 1001, 'status': 'active'}

四、避坑指南:装饰类中实例方法时的“描述符陷阱”

无参数类装饰器在装饰普通模块级函数时表现完美,但如果直接将它用于修饰另一个类中的实例方法,就会引发经典的 TypeError。

1. 陷阱表现与产生原因

观察以下直观的反例:

class UserService:
    @CallCounter
    def query_name(self, uid):
        return f"User_{uid}"

service = UserService()
# 执行调用时报错:
# TypeError: calculate_tax() missing 1 required positional argument: 'uid'
service.query_name(101)

原因分析:在 Python 中,类方法之所以在通过 service.query_name() 调用时能自动把 service 自身作为首个参数(即 self)注入,是因为函数对象内部实现了描述符协议(Descriptor Protocol,即 __get__ 方法)。当我们在类中用 @CallCounter 装饰方法后,query_name 变成了 CallCounter 的一个实例。由于该实例没有定义 __get__ 方法,Python 无法对其进行方法绑定(Bound Method),调用时传入的 101 被错误当作了 self,导致参数位置错位。

2. 规范解决方案:实现描述符协议

要在类装饰器中完美兼容普通函数与类实例方法,只需在装饰器类中补全 __get__ 描述符方法:

import types
import functools

class UniversalLogger:
    def __init__(self, func):
        self.func = func
        functools.update_wrapper(self, func)
        self.call_count = 0

    def __call__(self, *args, **kwargs):
        self.call_count += 1
        print(f"[LOG] 执行函数 {self.func.__name__},参数: args={args}, kwargs={kwargs}")
        return self.func(*args, **kwargs)

    def __get__(self, instance, owner):
        # 如果是类对象直接访问(如 UserService.query_name),直接返回自身
        if instance is None:
            return self
        # 如果是通过类实例访问,利用 MethodType 将当前装饰器实例与宿主实例绑定
        return types.MethodType(self, instance)

验证支持普通函数与实例方法双场景:

class AccountService:
    @UniversalLogger
    def deposit(self, amount):
        return f"成功存入 {amount} 元"

service = AccountService()
print(service.deposit(500))
print(f"该方法被调用了 {AccountService.deposit.call_count} 次")
示例输出
[LOG] 执行函数 deposit,参数: args=(500,), kwargs={}
成功存入 500 元
该方法被调用了 1 次

五、选型建议与总结:何时选函数,何时选类?

类装饰器和函数装饰器各具特长。在实际工程项目中,可以按照以下决策逻辑进行选型:

除了函数,Python怎么用类实现装饰器?
图 4:Python 装饰器选型决策树与场景匹配指南
选型维度 函数装饰器 (Function Decorator) 类装饰器 (Class Decorator)
主要适用场景 轻量切面:执行耗时统计、基础入参校验、统一日志打印 富状态管理:调用频次限流、复杂本地缓存、需对外暴露管理接口
状态存储机制 依赖闭包或 nonlocal,多状态变量时代码繁琐 作为类的 self 属性天然隔离,支持任意复杂数据结构
扩展性与多态 扩展困难,复用通常需要进一步嵌套高阶函数 面向对象原生支持:支持子类继承、覆写模板方法与多态定制
修饰类方法的成本 天然支持(底层函数自带标准描述符) 需显式实现 __get__ 描述符协议才能正常绑定 self

总结来说,Python 用类实现装饰器的核心在于将原本分散在闭包内的状态提升为明确的实例属性。无参装饰器让 __init__ 接收原函数、由 __call__ 充当执行体;带参装饰器则由 __init__ 接收配置、由 __call__ 接收函数并返回闭包。只要把握好 __call__ 的入参职责,并在需要装饰类方法时配合 __get__ 描述符协议,就能编写出结构清晰、内聚度高且极易测试的高质量装饰器。

相关标签:

免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。

Python 相关产品和应用

全部下载
Selenium

Selenium

4.51.0 · Windows

用于浏览器自动化和 Web 测试的 Python 工具。

scikit-learn

scikit-learn

1.9.1 · Windows

常用 Python 机器学习算法与模型评估工具库。

Black

Black

26.10.1 · Windows

强调一致性的 Python 自动代码格式化工具。

Ruff

Ruff

0.17.1 · macOS

由 Rust 编写的高速 Python 代码检查和格式化工具。

pytest

pytest

9.1.1 · Windows

简洁而强大的 Python 自动化测试框架。

FastAPI

FastAPI

0.146.0 · Windows

高性能 Python API 框架,支持类型提示和自动接口文档。

Flask

Flask

3.1.3 · Windows

轻量灵活的 Python Web 应用框架。

Django

Django

6.1.2 · Windows

成熟的 Python Web 框架,适合构建内容站点和业务系统。

Spyder

Spyder

6.1.7 · Windows

面向科学计算与数据分析的 Python 集成开发环境。

SciPy

SciPy

1.18.1 · Windows

面向科学、工程和技术计算的 Python 算法库。