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

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

Python装饰器闭包高阶函数

引言

这是第四章「函数」的第 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):内层函数可以引用外层函数的变量,即使外层函数已经返回。

让我们从最朴素的版本开始理解。假设我们想给任意函数加上「打印调用日志」的功能:

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 作用于一个函数:

def greet():
    return "Hello"

greet = my_decorator(greet)
print(greet())
# Calling greet
# greet returned Hello

这就是装饰器的工作原理:用一个新函数包裹原函数,在调用前后插入额外逻辑。

@decorator 语法糖

上面的 greet = my_decorator(greet) 等价于在函数定义前加上 @my_decorator:

@my_decorator
def greet():
    return "Hello"

print(greet())
# Calling greet
# greet returned Hello

@decorator 是 Python 的语法糖,编译器会在函数定义完成后立即把该函数当作参数传给装饰器,并将返回值绑定到原函数名上。这两段代码在运行时效果完全相同。

语法糖的意义在于让装饰器的使用变得声明式而非命令式——你看到的是「这个函数被这个装饰器装饰」,而不是「把函数传给装饰器再赋值回来」。

为什么需要 wraps

用前面朴素版本的装饰器装饰 greet 之后,你会发现一个问题:

print(greet.__name__)  # 'wrapper',不是 'greet'
print(greet.__doc__)   # None,文档字符串丢失了

原因在于 greet 现在指向的是 wrapper 函数,而 wrapper 的 __name__ 是 'wrapper',__doc__ 也是默认的 None。原函数的所有元数据都被覆盖了。

这在调试时会很麻烦—— traceback 里显示的是 wrapper 而不是真正的函数名。functools.wraps 就是为了解决这个问题而存在的:

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:

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

现在它可以装饰任意签名(任意参数数量、任意类型)的函数:

@my_decorator
def add(a, b):
    return a + b

print(add(3, 4))
# Calling add with args=(3, 4), kwargs={}
# add returned 7
# 7

日志装饰器

最实用的装饰器之一是日志装饰器——它记录每次函数调用的入参和返回值:

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 时间:

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)**策略管理缓存:

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 装饰的函数。

查看缓存状态:

print(fib.cache_info())
# hits: 缓存命中次数(相同参数再次调用时直接返回缓存值)
# misses: 缓存未命中次数(需要实际计算)
# maxsize: 最大缓存容量
# currsize: 当前缓存中的条目数

第二次调用 fib(10) 时 hits 会加 1,因为结果已经在缓存中:

print(fib(10))        # 55
print(fib.cache_info())
# CacheInfo(hits=1, misses=11, maxsize=None, currsize=11)

手动清除缓存:

fib.cache_clear()
print(fib.cache_info())
# CacheInfo(hits=0, misses=0, maxsize=None, currsize=0)

权限校验装饰器

装饰器可以用于控制访问权限,这是 Web 框架鉴权的典型思路:

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

多层装饰器与执行顺序

多个装饰器可以叠加使用,书写顺序是从下到上(从近到远)依次执行,但调用时顺序相反:

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。

你可以把装饰器想象成俄罗斯套娃——最外层是最后应用的装饰器,最内层是最原始函数。调用时从外往里剥,返回时从里往外套。

记忆口诀:@ 从下往上叠加,调用从上往下执行。 这句话可以帮你快速判断任意多层装饰器的执行结果,而不需要去画函数调用图。

实际项目中,常见的多层装饰器组合是「计时 + 缓存 + 日志」,下面是一个完整的示例:

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 而非原函数名
# ❌ 危险写法:丢失元数据
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)),这时需要三层嵌套:

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 的存在与否:

class MyClass:
    @my_decorator
    def method(self, x):
        return x * 2

这里 my_decorator 包裹的 wrapper 收到的第一个参数是 self,func(*args, **kwargs) 会正确地把 self 传进去,不需要特殊处理。但如果你用 @staticmethod,self 就不存在了,要小心参数数量的变化。

装饰器的性能开销

每次函数调用都会经过装饰器额外生成的 wrapper 函数,这意味着有微小的调用开销(函数调用本身 + 闭包变量查找)。对于高频调用的热点函数(每秒上百万次),这个开销可以被测量到:

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>')。

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 的限制源于其单表达式的设计。以下写法全部非法:

# ❌ 赋值语句
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 一样会中招。考虑这个常见错误:

# 错误:所有函数都返回最后一个 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() 返回的是迭代器对象,而不是列表。这意味着:

  • 只能遍历一次(消费后即空)
  • 惰性求值,按需计算
  • 内存效率更高,尤其对大数据集
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. 下面代码的输出是什么?解释原因。

    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 扩展层做优化)?