## 引言
这是第四章「函数」的第 5 篇。第 1 篇已经讲过 `def` 的定义与返回值,第 2 篇讲清了作用域和 LEGB 规则(这是理解装饰器内部闭包的基础),第 3 篇讲了可变参数 `*args` / `**kwargs`(这是装饰器能够透明包装任意函数的关键),第 4 篇介绍了 lambda 函数(高阶函数的常见伴侣)。本篇顺理成章地讨论:**当函数可以像整数一样被传递时,我们能不能「修改」一个函数的行为而不改它的代码?**
答案是可以的——这就是**装饰器(decorator)**。装饰器是 Python 中最优雅的特性之一,它把日志记录、性能计时、权限校验、结果缓存等「横切关注点」(cross-cutting concerns)从业务逻辑中剥离出来,让函数保持专注。Django 的 `@login_required`、Flask 的 `@app.route`、标准库的 `@functools.lru_cache` 都是装饰器的经典应用。
但装饰器也有一个常见的坑:被装饰后的函数会丢失原函数的元信息(名称、文档字符串等),导致调试困难。本篇会详细讲解 `functools.wraps` 如何解决这个问题。读完本篇,你不仅能读懂别人写的装饰器,还能自己写出生产级的日志计时器、缓存装饰器和多层装饰器。
## 概念与原理
### 装饰器的本质
装饰器本质上就是一个**接收函数作为参数、返回一个新函数的函数**。它利用了 Python 的两个核心能力:
1. **函数是一等公民**:函数可以作为参数传入其他函数,也可以作为返回值返回。
2. **闭包(closure)**:内层函数可以引用外层函数的变量,即使外层函数已经返回。
让我们从最朴素的版本开始理解。假设我们想给任意函数加上「打印调用日志」的功能:
```python
def my_decorator(func):
def wrapper():
print(f"Calling {func.__name__}")
result = func()
print(f"{func.__name__} returned {result}")
return result
return wrapper
```
这里 `wrapper` 是一个闭包——它「捕获」了外层函数参数 `func` 的引用。每次调用 `my_decorator` 时,都会创建一个新的 `wrapper` 函数,这个函数记住了当时传入的 `func`。
把 `my_decorator` 作用于一个函数:
```python
def greet():
return "Hello"
greet = my_decorator(greet)
print(greet())
# Calling greet
# greet returned Hello
```
这就是装饰器的工作原理:**用一个新函数包裹原函数,在调用前后插入额外逻辑**。
### @decorator 语法糖
上面的 `greet = my_decorator(greet)` 等价于在函数定义前加上 `@my_decorator`:
```python
@my_decorator
def greet():
return "Hello"
print(greet())
# Calling greet
# greet returned Hello
```
`@decorator` 是 Python 的**语法糖**,编译器会在函数定义完成后立即把该函数当作参数传给装饰器,并将返回值绑定到原函数名上。这两段代码在运行时效果完全相同。
语法糖的意义在于让装饰器的使用变得声明式而非命令式——你看到的是「这个函数被这个装饰器装饰」,而不是「把函数传给装饰器再赋值回来」。
### 为什么需要 wraps
用前面朴素版本的装饰器装饰 `greet` 之后,你会发现一个问题:
```python
print(greet.__name__) # 'wrapper',不是 'greet'
print(greet.__doc__) # None,文档字符串丢失了
```
原因在于 `greet` 现在指向的是 `wrapper` 函数,而 `wrapper` 的 `__name__` 是 `'wrapper'`,`__doc__` 也是默认的 `None`。原函数的所有元数据都被覆盖了。
这在调试时会很麻烦—— traceback 里显示的是 `wrapper` 而不是真正的函数名。`functools.wraps` 就是为了解决这个问题而存在的:
```python
from functools import wraps
def my_decorator(func):
@wraps(func)
def wrapper():
print(f"Calling {func.__name__}")
result = func()
print(f"{func.__name__} returned {result}")
return result
return wrapper
@my_decorator
def greet():
"""Say hello."""
return "Hello"
print(greet.__name__) # 'greet'
print(greet.__doc__) # 'Say hello.'
```
`@wraps(func)` 是一个特殊的装饰器,它会把 `func` 的 `__name__`、`__doc__`、`__module__`、`__qualname__`、`__annotations__` 和 `__dict__` 拷贝到 `wrapper` 上。你可以把它理解为「伪造一份原函数的身份证」,让被装饰的函数在外部看起来和原来一模一样。
**实际工程建议:写装饰器时,永远在内部函数上加 `@wraps(func)`。** 这不仅是为了可读性,也是为了让反射工具(如 `inspect`、测试框架、序列化库)能正确工作。
## 操作与实现
### 通用装饰器:处理任意参数
前面 `wrapper()` 不接受参数,因此只能装饰无参函数。要让装饰器能用于任何函数,需要用 `*args` 和 `**kwargs`:
```python
from functools import wraps
def my_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
```
现在它可以装饰任意签名(任意参数数量、任意类型)的函数:
```python
@my_decorator
def add(a, b):
return a + b
print(add(3, 4))
# Calling add with args=(3, 4), kwargs={}
# add returned 7
# 7
```
### 日志装饰器
最实用的装饰器之一是日志装饰器——它记录每次函数调用的入参和返回值:
```python
import functools
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
logger.info(f"[{func.__name__}] called with args={args!r}, kwargs={kwargs!r}")
result = func(*args, **kwargs)
logger.info(f"[{func.__name__}] returned {result!r}")
return result
return wrapper
@log_calls
def compute(x, y):
return x * y + 1
compute(3, 4)
# INFO - [compute] called with args=(3, 4), kwargs={}
# INFO - [compute] returned 13
```
注意 `!r` 格式化符号会让参数以 `repr()` 形式显示,这对调试更友好。
### 计时装饰器
计时装饰器是性能分析的基础工具,用 `time.perf_counter`(而非 `time.time`)可以获得最高精度的.wall clock 时间:
```python
import functools
import time
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.6f} seconds")
return result
return wrapper
@timer
def slow_sum(n):
return sum(range(n))
result = slow_sum(1_000_000)
print(f"Result: {result}")
# slow_sum took 0.012867 seconds
# Result: 499999500000
```
实际运行这段代码,我得到的耗时约 0.0129 秒(机器不同会有波动),这个量级对单次百万次加法是合理的。计时装饰器在排查性能瓶颈时非常有用——把它挂到疑似慢的函数上,比手动插 `time.time()` 代码简洁得多。
### 缓存装饰器:functools.lru_cache 入门
Python 标准库提供了 `functools.lru_cache`,它是一个现成的「结果缓存」装饰器,用**最近最少使用(LRU,Least Recently Used)**策略管理缓存:
```python
import functools
@functools.lru_cache(maxsize=None)
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
print(fib(10)) # 55
print(fib.cache_info())
# CacheInfo(hits=8, misses=11, maxsize=None, currsize=11)
```
`lru_cache` 的参数:
- `maxsize`:缓存的最大条目数,设为 `None` 表示无上限。
- `typed`:默认 `False`;设为 `True` 时,不同类型参数分开缓存(`fib(3)` 和 `fib(3.0)` 视为不同调用)。
被缓存的函数必须满足:**所有参数都是可哈希的**(hashable)。列表、字典等可变对象不能直接作为参数传给被 `lru_cache` 装饰的函数。
查看缓存状态:
```python
print(fib.cache_info())
# hits: 缓存命中次数(相同参数再次调用时直接返回缓存值)
# misses: 缓存未命中次数(需要实际计算)
# maxsize: 最大缓存容量
# currsize: 当前缓存中的条目数
```
第二次调用 `fib(10)` 时 `hits` 会加 1,因为结果已经在缓存中:
```python
print(fib(10)) # 55
print(fib.cache_info())
# CacheInfo(hits=1, misses=11, maxsize=None, currsize=11)
```
手动清除缓存:
```python
fib.cache_clear()
print(fib.cache_info())
# CacheInfo(hits=0, misses=0, maxsize=None, currsize=0)
```
### 权限校验装饰器
装饰器可以用于控制访问权限,这是 Web 框架鉴权的典型思路:
```python
import functools
def require_login(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
user = kwargs.get("user") or getattr(args[0], "user", None) if args else None
if not user or user.get("is_authenticated", False):
return func(*args, **kwargs)
raise PermissionError(f"User {user} is not authenticated")
return wrapper
@require_login
def delete_post(post_id, user):
return f"Post {post_id} deleted"
# 未认证用户会抛出异常
# delete_post(1, {"is_authenticated": False}) # PermissionError
print(delete_post(1, {"is_authenticated": True})) # Post 1 deleted
```
### 多层装饰器与执行顺序
多个装饰器可以叠加使用,书写顺序是从下到上(从近到远)依次执行,但调用时顺序相反:
```python
import functools
def decorator1(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"dec1({func(*args, **kwargs)})"
return wrapper
def decorator2(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"dec2({func(*args, **kwargs)})"
return wrapper
@decorator1
@decorator2
def say_hello():
return "hello"
print(say_hello()) # dec1(dec2(hello))
```
执行过程如下:
1. 先执行 `@decorator2`:`say_hello` 被 `decorator2` 包裹,变成 `dec2_wrapper`。
2. 再执行 `@decorator1`:`dec2_wrapper` 被 `decorator1` 包裹,变成 `dec1_wrapper`。
3. 调用 `say_hello()` 时,实际调用的是 `dec1_wrapper`,它在内部调用 `dec2_wrapper`,最终调用原始的 `say_hello`。
你可以把装饰器想象成俄罗斯套娃——最外层是最后应用的装饰器,最内层是最原始函数。调用时从外往里剥,返回时从里往外套。
**记忆口诀:`@` 从下往上叠加,调用从上往下执行。** 这句话可以帮你快速判断任意多层装饰器的执行结果,而不需要去画函数调用图。
实际项目中,常见的多层装饰器组合是「计时 + 缓存 + 日志」,下面是一个完整的示例:
```python
import functools
import time
import logging
logger = logging.getLogger(__name__)
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
logger.info(f"Calling {func.__name__}")
start = time.perf_counter()
result = func(*args, **kwargs)
logger.info(f"{func.__name__} took {time.perf_counter() - start:.4f}s")
return result
return wrapper
def cache_results(func):
cache = {}
@functools.wraps(func)
def wrapper(*args, **kwargs):
key = args + tuple(sorted(kwargs.items()))
if key in cache:
return cache[key]
result = func(*args, **kwargs)
cache[key] = result
return result
return wrapper
@log_calls
@cache_results
def expensive_compute(n):
return sum(i * i for i in range(n))
print(expensive_compute(1000)) # 首次计算,有日志
print(expensive_compute(1000)) # 命中缓存,仍然有日志(日志在外层)
```
这里两层装饰器的顺序决定了行为:`@cache_results` 在内层,先检查缓存;`@log_calls` 在外层,无论是否命中缓存都会记录日志。如果把顺序颠倒,日志就会把缓存查到的结果也当作「调用」来记录,失去区分实际计算和缓存命中的能力。所以在设计多层装饰器时,执行的先后顺序就是你想要的行为层次。
## 易错点与陷阱
### 陷阱一:忘记使用 functools.wraps
没有 `@wraps` 的装饰器会导致原函数的元数据丢失。这不仅仅是名字变了的问题——它会破坏依赖反射的工具,比如:
- `inspect.getmembers()` 拿不到正确的函数列表
- 测试框架(pytest、unittest)的报告里显示错误的函数名
- 序列化库在序列化函数对象时出错
- 调试器(pdb)在断点命中时显示 `wrapper` 而非原函数名
```python
# ❌ 危险写法:丢失元数据
def bad_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
# ✅ 正确写法:保留元数据
import functools
def good_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
```
### 陷阱二:装饰器工厂(带参数的装饰器)三层嵌套
有时我们希望装饰器本身接收参数(比如 `@log_calls(level=logging.DEBUG)`),这时需要**三层嵌套**:
```python
import functools
import logging
def log_calls(level=logging.INFO):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
logger = logging.getLogger(func.__module__)
logger.log(level, f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
return decorator
@log_calls(level=logging.DEBUG)
def add(a, b):
return a + b
```
第一层 `log_calls(level=...)` 接收装饰器参数,返回第二层的 `decorator`;第二层 `decorator(func)` 接收被装饰函数,返回第三层的 `wrapper`。三层嵌套容易让人晕,记住结构就好:最外层收参数,中间层收函数,最内层收调用参数。
### 陷阱三:类方法装饰器的 self 处理
对 `@staticmethod` 或 `@classmethod` 应用自定义装饰器时,需要注意 `self` 的存在与否:
```python
class MyClass:
@my_decorator
def method(self, x):
return x * 2
```
这里 `my_decorator` 包裹的 `wrapper` 收到的第一个参数是 `self`,`func(*args, **kwargs)` 会正确地把 `self` 传进去,不需要特殊处理。但如果你用 `@staticmethod`,`self` 就不存在了,要小心参数数量的变化。
### 装饰器的性能开销
每次函数调用都会经过装饰器额外生成的 `wrapper` 函数,这意味着有微小的调用开销(函数调用本身 + 闭包变量查找)。对于高频调用的热点函数(每秒上百万次),这个开销可以被测量到:
```python
import functools
import time
def noop_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@noop_decorator
def raw_func(x):
return x + 1
# 两者结果完全相同,但 wrapper 多一次调用栈
print(raw_func(10)) # 11
```
实际上这种开销通常可以忽略不计——除非你在做高性能数值计算(如 NumPy 向量化操作),否则装饰器带来的额外开销远小于函数体本身的执行时间。在 Web 应用中,一个 HTTP 请求的网络延迟远比装饰器的开销大几个数量级。
### 性能与最佳实践对比
在实际工程中,选择 lambda 还是 def 不应仅基于语法简洁度,而应综合考虑可读性、调试便利性与性能特征。
**lambda 的性能特征**:lambda 与等效的 def 函数在字节码层面几乎一致——CPython 对两者的编译策略相同,都生成一个函数对象(`<lambda>` vs `<module>` 内部函数)。用 `dis` 模块反汇编可见,lambda 的字节码指令序列与等效 def 基本相同,只有函数名称字段有差异(`co_name` 为 `'<lambda>'`)。
```python
import dis
f = lambda x: x * 2
g = lambda x: x * 2
print(f == g) # True:两个独立 lambda 对象,但功能相同
print(f is g) # False:不同对象
def h(x): return x * 2
print(dis.compare_code(f.__code__, h.__code__)) # 指令序列相同
```
**何时该用 lambda**:
1. 需要临时传递一个小函数作为参数(如 `sorted` 的 `key`、`filter` 的谓词)
2. 函数体只有一行表达式,且逻辑简单到一眼能看懂
3. 不需要命名、不会复用、不会递归
**何时不该用 lambda**:
1. 逻辑复杂需要多行——改用 def,或在 lambda 内调用辅助函数
2. 需要调试——lambda 的 `__name__` 是 `<lambda>`,栈追踪难以辨认
3. 需要类型注解——lambda 不支持注解,可读性差
4. 需要文档字符串——lambda 无法附带 docstring
记住一条原则:**可读性永远优先于简洁性**。如果你的同事(或三个月后的你)看到这段代码需要停顿思考,那就应该写成 def。
---
## 易错点与陷阱
### 陷阱一:lambda 不能包含语句
lambda 的限制源于其单表达式的设计。以下写法全部非法:
```python
# ❌ 赋值语句
bad = lambda x: (y := x + 1, y * 2) # 虽然walrus运算符在3.8+可用,但不推荐
# ❌ 循环
bad = lambda x: [print(i) for i in range(x)] # 这是列表推导式,不是循环语句
# ❌ 条件分支多行
bad = lambda x:
if x > 0:
return "positive"
else:
return "negative"
```
想表达复杂逻辑时,老老实实用 def。lambda 只适合那些"一行说清楚"的场景。
### 陷阱二:late binding 闭包陷阱在 lambda 中同样存在
第 2 篇讲过的闭包陷阱,lambda 一样会中招。考虑这个常见错误:
```python
# 错误:所有函数都返回最后一个 x 的值
funcs = [lambda x, i=i: x ** i for i in range(5)] # 正确:用默认参数捕获
print([f(2) for f in funcs]) # [1, 2, 4, 8, 16]
# 错误写法(闭包陷阱)
funcs_bad = [lambda x: x ** i for i in range(5)]
print([f(2) for f in funcs_bad]) # [16, 16, 16, 16, 16]:i 是自由变量,延迟绑定
```
这与普通函数中的闭包行为完全一致。解决方案也是同一个:用默认参数值固化当前迭代状态。
### 陷阱三:map/filter 返回迭代器而非列表
Python 3 中,`map()` 和 `filter()` 返回的是迭代器对象,而不是列表。这意味着:
- 只能遍历一次(消费后即空)
- 惰性求值,按需计算
- 内存效率更高,尤其对大数据集
```python
squares = map(lambda x: x ** 2, range(10))
print(type(squares)) # <class 'map'>
print(list(squares)) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
print(list(squares)) # []:迭代器已耗尽
```
如果需要列表,显式调用 `list()`;如果需要保留数据,考虑用列表推导式替代。
## 小结
装饰器的本质是「函数 → 函数」的高阶变换,`@decorator` 语法糖让这种变换变得声明式、易读。`functools.wraps` 是写装饰器时必须使用的工具,它能保留原函数的元数据,避免调试和反射工具出现混乱。常见装饰器模式包括日志记录、性能计时、结果缓存(`functools.lru_cache`)和权限校验;多层装饰器从下往上应用、从上往下执行,形成一层层函数包裹。掌握这些模式后,你就能把横切关注点和业务逻辑 cleanly 地分离开来。
## 练习与思考题
1. 实现一个 `retry(max_times=3)` 装饰器:被装饰的函数抛出异常时自动重试,直到成功或达到最大重试次数。提示:在 `wrapper` 里用 `for` 循环 + `try/except` 捕获异常。
2. 下面代码的输出是什么?解释原因。
```python
def d1(func):
def w(*a, **k):
return f"d1({func(*a, **k)})"
return w
def d2(func):
def w(*a, **k):
return f"d2({func(*a, **k)})"
return w
@d1
@d2
def f():
return "ok"
print(f())
```
3. 使用 `functools.lru_cache` 装饰一个计算阶乘的函数,验证调用相同参数时是否命中缓存。写出验证代码和 `cache_info()` 的输出。
4. 讨论:装饰器与普通函数调用的性能差异有多大?在什么场景下装饰器的额外开销值得忽略,什么场景下值得用替代方案(比如在 C 扩展层做优化)?