装饰器基础:从闭包到装饰器的自然演变

文章目录

一、从一个重复问题开始

假设有这样的需求:记录每个函数的执行时间。

最直接的写法:

python 复制代码
import time

def fetch_user(user_id):
    start = time.perf_counter()
    # 真正的逻辑
    result = {"id": user_id, "name": "Alice"}
    end = time.perf_counter()
    print(f"fetch_user 耗时 {end - start:.4f}s")
    return result

def fetch_order(order_id):
    start = time.perf_counter()
    result = {"id": order_id, "amount": 99.9}
    end = time.perf_counter()
    print(f"fetch_order 耗时 {end - start:.4f}s")
    return result

计时代码在每个函数里重复出现------如果有 50 个函数,就得粘贴 50 次。更糟的是,计时逻辑和业务逻辑混在一起,后者变得难以测试。

这就是装饰器要解决的问题:把横切关注点(计时、日志、认证等)从业务逻辑中剥离出来,独立维护。


二、装饰器的最小形态

在理解装饰器之前,先从一个最小的例子开始------一个不做任何修改的"透传"装饰器:

python 复制代码
def do_nothing(fn):
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

def greet(name):
    return f"Hello, {name}"

decorated = do_nothing(greet)
print(decorated("Alice"))  # Hello, Alice

这就是装饰器的核心结构:

  1. do_nothing 接收一个函数 fn(被装饰的函数)
  2. 内部定义 wrapper,用 *args, **kwargs 接收任意参数,转发给原函数
  3. 返回 wrapper

decorated 的类型是函数,是 wrapper,但它的行为和 greet 完全相同。

现在加上计时逻辑:

python 复制代码
import time

def timer(fn):
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = fn(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{fn.__name__} 耗时 {elapsed:.4f}s")
        return result
    return wrapper

def fetch_user(user_id):
    return {"id": user_id, "name": "Alice"}

fetch_user = timer(fetch_user)  # 手动装饰
fetch_user(42)                  # fetch_user 耗时 0.0000s

fetch_user = timer(fetch_user) 这一行做了三件事:

  1. 把原来的 fetch_user 函数传给 timer
  2. timer 返回了一个包含计时逻辑的 wrapper
  3. wrapper 重新绑定到 fetch_user 这个名字上

此后所有对 fetch_user 的调用,实际上调用的都是 wrapper


三、@ 语法糖:等价变换

Python 提供了 @ 语法,让装饰器的写法更简洁:

python 复制代码
@timer
def fetch_user(user_id):
    return {"id": user_id, "name": "Alice"}

这完全等价于:

python 复制代码
def fetch_user(user_id):
    return {"id": user_id, "name": "Alice"}

fetch_user = timer(fetch_user)

@timer 只是语法糖,Python 解释器在处理 def fetch_user(...) 的同时,自动执行了 fetch_user = timer(fetch_user)

验证这两种写法是等价的:

python 复制代码
import dis

# 写法一
source_a = """
@timer
def f():
    pass
"""

# 写法二
source_b = """
def f():
    pass
f = timer(f)
"""

# 编译后的字节码会包含完全相同的 CALL 指令

@ 语法的价值在于:装饰器声明紧跟函数定义,阅读代码时一眼就能看到函数被哪些装饰器处理过。分开写时,函数定义和装饰操作可能相隔很远,容易遗漏。


四、functools.wraps:为什么不能省

现在有一个问题。使用 timer 装饰 fetch_user 后:

python 复制代码
@timer
def fetch_user(user_id):
    """根据 user_id 获取用户信息"""
    return {"id": user_id, "name": "Alice"}

print(fetch_user.__name__)    # wrapper(而不是 fetch_user)
print(fetch_user.__doc__)     # None(而不是函数的文档字符串)

fetch_user 变成了 wrapper------名字、文档字符串、注解全都丢失了。这在生产代码中会引发严重问题:日志里的函数名全是 wrapperhelp(fetch_user) 显示的是错误的文档;pytest 在测试报告中无法正确标记失败的函数。

functools.wraps 解决这个问题:

python 复制代码
import functools
import time

def timer(fn):
    @functools.wraps(fn)  # 把 fn 的元信息复制到 wrapper 上
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = fn(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{fn.__name__} 耗时 {elapsed:.4f}s")
        return result
    return wrapper

@timer
def fetch_user(user_id):
    """根据 user_id 获取用户信息"""
    return {"id": user_id, "name": "Alice"}

print(fetch_user.__name__)   # fetch_user ✓
print(fetch_user.__doc__)    # 根据 user_id 获取用户信息 ✓

functools.wraps 的底层机制

@functools.wraps(fn) 本身也是一个装饰器------装饰器用来装饰装饰器内部的 wrapper。它等价于:

python 复制代码
wrapper = functools.update_wrapper(wrapper, fn)

update_wrapper 把以下属性从 fn 复制到 wrapper

属性 内容
__name__ 函数名
__qualname__ 完全限定名(包含类名等)
__doc__ 文档字符串
__dict__ 函数的自定义属性字典(浅拷贝)
__annotations__ 类型注解
__module__ 所在模块名
__wrapped__ 指向原始函数的指针(新增,用于解包)

最后一个 __wrapped__ 特别实用:

python 复制代码
@timer
def calculate(x, y):
    return x + y

# 获取原始函数(绕过装饰器)
original = calculate.__wrapped__
print(original(1, 2))  # 3,不打印计时信息

在单元测试中,__wrapped__ 让测试代码直接调用原始函数,不经过装饰器逻辑,保证测试的纯粹性。


五、多层装饰器的执行顺序

当多个装饰器叠加时,执行顺序遵循一个固定规则。先看代码:

python 复制代码
def decorator_a(fn):
    print(f"装饰器 A 包装 {fn.__name__}")
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        print(f"A: 调用 {fn.__name__} 之前")
        result = fn(*args, **kwargs)
        print(f"A: 调用 {fn.__name__} 之后")
        return result
    return wrapper

def decorator_b(fn):
    print(f"装饰器 B 包装 {fn.__name__}")
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        print(f"B: 调用 {fn.__name__} 之前")
        result = fn(*args, **kwargs)
        print(f"B: 调用 {fn.__name__} 之后")
        return result
    return wrapper

@decorator_a
@decorator_b
def target():
    print("target 执行")

输出(定义阶段):

复制代码
装饰器 B 包装 target
装饰器 A 包装 target

输出(调用 target() 时):

复制代码
A: 调用 target 之前
B: 调用 target 之前
target 执行
B: 调用 target 之后
A: 调用 target 之后

规律

  • 包装阶段(定义时):从下到上执行,先 B 再 A
  • 调用阶段(运行时):从外到内进入(A 先、B 后),从内到外返回(B 先、A 后)

理解这个规律,关键是把 @decorator_a @decorator_b def target() 展开:

python 复制代码
# 等价代码
target = decorator_a(decorator_b(target))
#                   ^--- 先执行 decorator_b(target)
#         ^--- 再执行 decorator_a(...)

decorator_b 先包装 target,返回 wrapper_bdecorator_a 再包装 wrapper_b,返回 wrapper_a。调用 target() 时,实际调用的是 wrapper_a,它内部调用 wrapper_bwrapper_b 才调用原始 target
调用 target()
进入 decorator_a 的 wrapper
A: 调用前
进入 decorator_b 的 wrapper
B: 调用前
执行原始 target()
B: 调用后
返回 decorator_b 的 wrapper
A: 调用后
返回 decorator_a 的 wrapper
返回最终结果


六、装饰器在执行上下文中的位置

装饰器的定义调用发生在不同的时间点,这是理解装饰器行为的关键:

python 复制代码
print("模块加载开始")

def logger(fn):
    print(f"  [装饰] {fn.__name__}")  # 模块加载时执行
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        print(f"  [调用] {fn.__name__}")  # 每次调用时执行
        return fn(*args, **kwargs)
    return wrapper

print("定义装饰函数...")

@logger
def api_get():
    return "data"

@logger
def api_post():
    return "created"

print("模块加载结束")
print("开始调用...")
api_get()
api_post()

输出:

复制代码
模块加载开始
定义装饰函数...
  [装饰] api_get
  [装饰] api_post
模块加载结束
开始调用...
  [调用] api_get
  [调用] api_post

装饰器的包装逻辑([装饰] 部分)在模块被 import就已经执行完毕,而不是等到函数被调用时。这个特性在以下场景中需要格外注意:

  1. 注册机制 :框架(如 Flask 的路由 @app.route)利用这个特性在导入模块时自动注册路由
  2. 副作用:如果装饰器的包装逻辑有副作用(如连接数据库),模块导入时就会触发

七、装饰器参数检查的实战技巧

很多装饰器需要对参数做验证,比如确保某个参数是正整数:

python 复制代码
import functools
from typing import Callable, Any

def validate_positive(fn: Callable) -> Callable:
    """确保第一个位置参数是正数"""
    @functools.wraps(fn)
    def wrapper(*args, **kwargs) -> Any:
        if args:
            first_arg = args[0]
            if isinstance(first_arg, (int, float)) and first_arg <= 0:
                raise ValueError(
                    f"{fn.__name__} 的第一个参数必须是正数,收到了 {first_arg!r}"
                )
        return fn(*args, **kwargs)
    return wrapper

@validate_positive
def calculate_discount(price, rate=0.1):
    return price * (1 - rate)

@validate_positive
def generate_range(n):
    return list(range(n))

# 正常情况
print(calculate_discount(100))     # 90.0

# 传入负数时
try:
    calculate_discount(-50)
except ValueError as e:
    print(e)  # calculate_discount 的第一个参数必须是正数,收到了 -50

这个装饰器通过 args[0] 访问第一个位置参数,避免了在每个函数里手写参数校验逻辑。


八、inspect.signature:让装饰器感知签名

上面的 validate_positive 只能验证位置参数,无法处理关键字传参的情况(calculate_discount(price=-50))。inspect.signature 可以解决这个问题:

python 复制代码
import inspect
import functools

def validate_positive_v2(fn):
    sig = inspect.signature(fn)
    
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        # 把所有参数绑定到签名
        bound = sig.bind(*args, **kwargs)
        bound.apply_defaults()  # 补上默认值
        
        # 遍历所有参数
        for name, value in bound.arguments.items():
            if isinstance(value, (int, float)) and value <= 0:
                raise ValueError(
                    f"{fn.__name__}() 的参数 {name!r} 必须是正数,收到了 {value!r}"
                )
        return fn(*args, **kwargs)
    return wrapper

@validate_positive_v2
def calculate_bill(price, tax_rate=0.1):
    return price * (1 + tax_rate)

try:
    calculate_bill(price=100, tax_rate=-0.5)
except ValueError as e:
    print(e)
# calculate_bill() 的参数 'tax_rate' 必须是正数,收到了 -0.5

inspect.signature(fn).bind(*args, **kwargs) 把实际传入的参数绑定到函数签名上,返回的 BoundArguments 可以统一以字典形式访问所有参数------无论是位置传参还是关键字传参,都能处理。


九、类方法的装饰器:self 的注意事项

把装饰器用在类方法上时,self(实例引用)会作为第一个参数传入 wrapper

python 复制代码
import functools
import time

def method_timer(fn):
    """专门用于类方法的计时装饰器"""
    @functools.wraps(fn)
    def wrapper(self, *args, **kwargs):
        start = time.perf_counter()
        result = fn(self, *args, **kwargs)
        elapsed = time.perf_counter() - start
        cls_name = type(self).__name__
        print(f"{cls_name}.{fn.__name__} 耗时 {elapsed:.4f}s")
        return result
    return wrapper

class DataPipeline:
    def __init__(self, name):
        self.name = name
    
    @method_timer
    def process(self, data):
        """处理数据"""
        result = [x ** 2 for x in data]
        return result
    
    @method_timer
    def aggregate(self, processed_data):
        """聚合数据"""
        return sum(processed_data)

pipeline = DataPipeline("test")
processed = pipeline.process(range(1000))
total = pipeline.aggregate(processed)
# DataPipeline.process 耗时 0.0001s
# DataPipeline.aggregate 耗时 0.0000s

通用装饰器(用 *args 接收所有参数)也能直接用在类方法上,因为 self 会被收进 args[0]。但显式写出 wrapper(self, *args, **kwargs) 更清晰,也能在 wrapper 内部访问实例属性。


十、基于装饰器的注册机制

装饰器另一个重要用途是自动注册。框架(Flask、Click)大量使用这个模式:

python 复制代码
class EventBus:
    """一个简单的事件总线"""
    _handlers = {}
    
    @classmethod
    def on(cls, event_name):
        """将函数注册为某事件的处理器"""
        def decorator(fn):
            if event_name not in cls._handlers:
                cls._handlers[event_name] = []
            cls._handlers[event_name].append(fn)
            return fn  # 注意:注册类装饰器通常返回原函数,不修改行为
        return decorator
    
    @classmethod
    def emit(cls, event_name, *args, **kwargs):
        """触发事件,调用所有注册的处理器"""
        handlers = cls._handlers.get(event_name, [])
        results = []
        for handler in handlers:
            results.append(handler(*args, **kwargs))
        return results

# 注册事件处理器
@EventBus.on("user.created")
def send_welcome_email(user_id, email):
    return f"发送欢迎邮件到 {email}"

@EventBus.on("user.created")
def create_default_profile(user_id, email):
    return f"创建用户 {user_id} 的默认配置"

@EventBus.on("order.placed")
def notify_inventory(order_id):
    return f"通知库存系统:订单 {order_id}"

# 触发事件
results = EventBus.emit("user.created", user_id=1, email="alice@example.com")
print(results)
# ['发送欢迎邮件到 alice@example.com', '创建用户 1 的默认配置']

这个模式的核心是:装饰器在函数定义时 就把函数注册到了 _handlers 字典里,之后通过事件名就能找到所有处理器并调用。Flask 的 @app.route("/api/users") 本质上就是这样工作的。


十一、总结:装饰器的本质与边界

装饰器的本质
高阶函数:接收函数,返回函数
闭包:wrapper 捕获 fn 作为自由变量
@语法:f = decorator(f) 的语法糖
两个关键规范
functools.wraps:保留元信息
*args/**kwargs:兼容任意签名
多层装饰器
包装顺序:从下到上
调用顺序:从外到内
应用场景
横切关注点分离

(计时/日志/认证)
注册机制

(路由/事件/命令)
参数校验

(类型/范围/权限)

装饰器是 Python 里最能体现"函数是一等公民"这一特性的语言机制。它把两个函数对象(装饰器函数和被装饰函数)通过闭包绑定在一起,使得通用逻辑能以最低的代码侵入性附着在业务函数上。

理解装饰器的关键认知路径:

  1. 函数是对象,可以作为参数和返回值 → #01
  2. 闭包能捕获外层变量,让 wrapper 持有 fn 的引用 → #02
  3. @ 语法是 f = decorator(f) 的等价写法 → 本文
  4. functools.wraps 是规范写法,不能省略 → 本文
  5. 带参数的装饰器、类装饰器 → #04

如果觉得本文对深入理解 Python 有帮助,欢迎点赞和收藏。本专栏专注 Python 工程化与底层机制,每一篇都有配套代码和原理剖析,关注不迷路。

相关推荐
dfdfadffa1 小时前
Golang Gin怎么做JWT登录认证_Golang Gin JWT教程【实用】
jvm·数据库·python
咸鱼翻身更入味1 小时前
Agent流式输送
前端
m0_736439301 小时前
C#怎么实现MVVM模式 C#如何在WPF中使用MVVM设计模式分离视图和逻辑【架构】
jvm·数据库·python
zhoutongsheng2 小时前
Chromebook适合用什么HTML函数工具_轻量化方案汇总【汇总】
jvm·数据库·python
万事大吉CC2 小时前
【4】深入剖析 Django 之 MTV:ORM 系统核心原理
数据库·python·oracle·django·sqlite
今天长肉了吗2 小时前
风控指标平台实战:大数据量下如何设计分批处理
开发语言·数据库·python
2301_782040452 小时前
JavaScript中丢失的this:回调函数中指向改变的对策
jvm·数据库·python
2301_818008442 小时前
MySQL从库出现数据同步异常中断_重新获取binlog坐标同步
jvm·数据库·python