AI 技术博客
返回首页
Python 基础 · 27 分钟阅读

装饰器入门:语法糖、functools.wraps 与日志计时器

## 引言 这是第四章「函数」的第 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 扩展层做优化)?