Python装饰器原理与实战:从闭包到避坑指南
做 Python 开发这几年装饰器属于那种“看过就懂一写就错”的语法特性。很多教程会说它“就是给函数打个补丁”听起来好像很简单可真到了项目里要写一个带重试、带日志、带缓存的装饰器各种奇怪问题马上冒出来函数名怎么变了、help 看不到文档了、类方法里的 self 怎么消失了、循环里生成的装饰器为什么闭了同一个变量。这些坑我基本都踩过一遍有的踩完还要在代码里留个 TODO 注释。Python 装饰器本质上是一个“函数替换”机制它能让你在不修改原函数代码的前提下给函数加日志、加权限校验、加缓存、加重试逻辑甚至可以完全替代原函数去做另一件事。这篇打算把装饰器的原理、闭包、参数传递、常见报错和工程实践一次说透适合刚学会函数语法的入门选手也适合写过装饰器但被某些细节卡过的朋友。1. 装饰器到底解决了什么问题1.1 函数是对象这是一切的起点理解装饰器之前必须先理解 Python 里“函数也是对象”这件事。定义一个函数其实就是创建了一个函数对象这个对象可以被赋值给变量可以被放进列表可以作为参数传给其他函数也可以作为返回值从函数里弹出来。def add(a, b): return a b f add print(f(1, 2)) # 3 print(f.__name__) # add这里f和add指向同一个函数对象调用f(1, 2)和调用add(1, 2)没有任何区别。既然函数是对象那我完全可以把一个函数作为参数传给另一个“处理函数”在处理函数里给参数函数包一层新逻辑再返回一个新的函数对象。这就是装饰器的核心机制没有任何黑魔法。1.2 语法到底做了什么事看下面这个最常见的写法def logger(func): def wrapper(*args, **kwargs): print(f调用 {func.__name__}) return func(*args, **kwargs) return wrapper logger def add(a, b): return a blogger这行代码执行完以后等价于def add(a, b): return a b add logger(add)关键在于装饰器不是“调用完之后装饰”而是在函数定义阶段就完成了替换。add这个名字最终指向的是logger(add)返回的wrapper函数而不是原来的函数体。之后任何地方调用add(1, 2)实际执行的都是wrapper(1, 2)wrapper内部再去调用原来的add。很多新手一开始理解不了“为什么装饰器函数里面还要再定义一个函数”其实就是因为它必须要返回一个可调用对象来替身原函数。如果不返回add logger(add)就会变成add None后面再调用add(1, 2)必然报错。2. 从零手写第一个装饰器顺便把闭包讲透2.1 最基础的日志装饰器长什么样先看一个最简单的日志装饰器它做的事是在函数调用前后各打一行日志import functools def log_call(func): functools.wraps(func) def wrapper(*args, **kwargs): print(f开始调用 {func.__name__}) result func(*args, **kwargs) print(f{func.__name__} 调用结束) return result return wrapper log_call def say_hello(name): return fhello {name} print(say_hello(Tom))这里的结构是一个标准三层递进关系外层函数log_call(func)接收原始函数中间层wrapper(*args, **kwargs)是替换后的新函数内部通过func(*args, **kwargs)调用原函数并把结果原样返回。整个结构看起来简单但里面有大量可以展开讲的细节。2.2 闭包、自由变量与 cell 对象为什么wrapper在log_call已经返回之后还能访问到func这是闭包机制在起作用。Python 里如果内部函数引用了外部函数作用域里的变量这个内部函数就会形成一个闭包。外部函数返回后被引用的变量不会销毁而是保存在一个叫cell的特殊对象里内部函数随时可以通过这个cell取出变量的值。def outer(): x 10 def inner(): return x return inner fn outer() print(fn()) # 10 print(fn.__closure__[0].cell_contents) # 10fn.__closure__是一个元组里面每个元素对应一个被捕获的自由变量。装饰器场景里最常见的自由变量就是funcwrapper通过闭包随时拿到原始函数来调用。这个机制还带来一个重要推论闭包保存的是变量本身不是变量在某个时刻的值。如果外层变量后续被修改闭包里看到的也是修改后的值。这个特性正是后面“循环里创建装饰器”各种翻车的根源后面会专门讲。2.3 别忘了一个关键约束返回值必须是可调用对象装饰器函数必须返回一个可调用对象返回什么add这个名字最终就指向什么。如果返回的不是函数实际调用时就会出现TypeError: NoneType object is not callable。有个隐藏版本在这里如果你写的有参装饰器不小心漏了一层返回的是装饰器工厂函数而不是真正的wrapper那么被装饰的函数调用时会返回一个函数对象而不是执行原函数。排查这类问题时我习惯直接在交互环境里看类型print(type(say_hello)) print(say_hello)一看say_hello是什么类型、打印出来长什么样基本就能定位是少写了一层 wrapper还是返回值写错了。3. 通用化改造参数处理、元信息保留与签名问题3.1 用 *args 和 **kwargs 兼容任意函数签名第一个装饰器里的wrapper(*args, **kwargs)不是随便写的。它表示“不管被装饰函数接受什么参数我都能接住并原样透传”。如果要装饰的函数签名不固定比如今天装饰add(a, b)明天装饰greet(name, greetinghi)后天装饰connect(host, port, timeout5)那么wrapper就必须用*args接收所有位置参数用**kwargs接收所有关键字参数再原封不动传给func。这样装饰器就和被装饰函数的签名解耦了。def log_call(func): functools.wraps(func) def wrapper(*args, **kwargs): print(f调用 {func.__name__}, args{args}, kwargs{kwargs}) return func(*args, **kwargs) return wrapper这里最需要记住的一件事是func(*args, **kwargs)一定要写 return。如果你写的是func(*args, **kwargs)而不带return那么被装饰函数如果有返回值在wrapper这一层就会被吞掉外部拿到的永远是None。这个坑非常经典新老手都容易犯。3.2 functools.wraps 不只是为了“好看”刚开始写装饰器时我偷懒跳过functools.wraps结果调试的时候发现所有被装饰函数的__name__都变成了wrapper打印日志全乱单元测试报告里也找不到原始函数名。更麻烦的是help()看不到原函数的文档字符串。这就是没保留元信息导致的。functools.wraps是一个专门用来“修补”wrapper 元信息的工具它会把原函数的__name__、__doc__、__module__、__qualname__等属性复制到 wrapper 上同时额外给 wrapper 设置一个__wrapped__属性指向原始函数。import functools def log_call(func): functools.wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper log_call def add(a, b): 计算两个数相加 return a b print(add.__name__) # add print(add.__doc__) # 计算两个数相加 print(add.__wrapped__) # function add at ...这个__wrapped__属性在第三方框架使用inspect.signature推断函数签名时非常关键。通过它工具链能绕开 wrapper 看到原始签名。所以只要不是刻意隐藏签名装饰器里都应该加上functools.wraps(func)。3.3 签名那点事inspect.signature 与wrapped就算加了functools.wraps也不代表签名问题完全消失。在 Python 3.4 之前inspect.signature(add)会把 wrapper 的签名(*args, **kwargs)当成交互界面原始参数信息看不到了这在 Web 框架、CLI 框架和文档生成器里很要命。Python 3.4 之后inspect.signature会在拥有__wrapped__属性的情况下默认跟随原始函数显示真实签名。但还有一个容易忽略的点如果你在装饰器里对参数做了转换比如把*args, **kwargs重新组合后才传给func那么即使inspect.signature显示的是原始签名实际传参逻辑也可能和签名对不上。这时候签名信息反而会误导调用者。如果确实需要严格保留签名并且还能修改参数形态可以使用wrapt这类第三方库里的decorator工具它能更精确地处理签名映射。对大多数项目来说functools.wraps已经够用了。4. 带参数的装饰器三层嵌套与兼容写法4.1 需求场景和三层结构拆解“今天的日志要记录 gre 格式明天的日志要记录 JSON 格式”这种可配置需求决定了装饰器本身也得接收参数。比如一个重试装饰器你得告诉它重试几次、间隔多少秒、捕获哪些异常。带参数的装饰器比普通装饰器多一层最外面是装饰器工厂它接收配置参数返回一个真正的装饰器中间的装饰器接收原始函数返回 wrapper最里面的 wrapper 才是实际执行的替身。三层结构我拆开写一遍import functools import time def retry(times3, delay0.1): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(times): try: return func(*args, **kwargs) except Exception as exc: if attempt times - 1: raise time.sleep(delay) return wrapper return decorator retry(times5, delay0.5) def download(url): # 模拟一个可能失败的网络请求 return fdownload {url}这里retry(times5, delay0.5)先执行返回decorator接着decorator把download作为参数传进去返回wrapper。整个过程翻译过来就是download retry(times5, delay0.5)(download)理解这个等价式你就能看穿一切有参装饰器的真面目。4.2 同时兼容 retry 和 retry(3) 的写法很多代码库希望使用者既能写retry也能写retry(3)。也就是同一个装饰器函数接受一个函数参数时不带括号接受配置参数时必须带括号。兼容写法是把装饰器工厂设计成可选参数模式import functools import time def retry(_funcNone, *, times3, delay0.1): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(times): try: return func(*args, **kwargs) except Exception as exc: if attempt times - 1: raise time.sleep(delay) return wrapper if _func is not None: return decorator(_func) return decorator retry def f1(): ... retry(times5, delay0.2) def f2(): ..._func作为第一个位置参数出现如果用户写retryPython 会把函数对象传进来如果用户写retry(times5)_func就是None直接返回decorator让语法糖继续套。这种写法在开源库里很常见但在自己项目中要权衡一下因为它会让类型提示和 IDE 推断变得不够直接团队里有人不熟悉就容易困惑。4.3 默认参数使用可变对象的坑写有参装饰器时默认参数尽量不要用列表、字典这类可变对象。比如def with_tags(tags[]): # 不推荐 def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): tags.append(call) return func(*args, **kwargs) return wrapper return decorator第一次调用后tags默认列表就被污染了第二次调用这个装饰器创建的新函数时tags里还留着上一次的历史数据。原因很简单函数定义时默认参数对象只创建一次之后每次不传都会复用同一个对象。正确写法是用tagsNone在函数内部再创建新列表def with_tags(tagsNone): if tags is None: tags [] ...这属于 Python 默认参数的基础问题但在装饰器工厂里因为又多包了一层函数非常容易被忽略。5. 类装饰器与标准库内置装饰器5.1 用类实现带状态的装饰器函数装饰器写多了会碰到一种需求希望维护跨调用的状态变量不能放在wrapper里因为每次调用都会重新进入函数体变量会重置。栈式外层的变量又容易被闭包捕获逻辑绕来绕去。此时用类实现装饰器会更直观。类装饰器依赖__call__方法实例对象本身变成可调用对象import functools class CountCalls: def __init__(self, func): functools.update_wrapper(self, func) self.func func self.count 0 def __call__(self, *args, **kwargs): self.count 1 return self.func(*args, **kwargs) CountCalls def add(a, b): return a b add(1, 2) add(3, 4) print(add.count) # 2类实例的属性天然可以作为跨调用的状态容器不需要想乱七八糟的闭包嵌套。注意functools.update_wrapper(self, func)可以在实例上直接复制函数元信息效果和functools.wraps类似。用类装饰器时有个小坑如果被装饰函数是方法执行顺序需要仔细推敲。类装饰器本质是一个可调用对象它接管了“函数调用”这个动作但不会自动处理self的传递所以__call__的签名里同样要用*args, **kwargs来接收第一个位置的self。5.2 functools.lru_cache 的正确打开方式标准库自带的functools.lru_cache是工程里高频使用的内置装饰器。它会把函数每次调用的参数和结果缓存下来相同参数再次进来时直接返回缓存结果用于加速纯计算型函数。from functools import lru_cache lru_cache(maxsize128) def fib(n): if n 2: return n return fib(n - 1) fib(n - 2)使用时有几个点必须想清楚被缓存函数的参数必须可哈希也就是说参数不能是列表、字典这类可变对象。传一个列表进去运行时会直接报TypeError: unhashable type: list解决方案通常是把参数转成元组或字符串。缓存是无界的其实maxsize控制最大条目数到了上限会淘汰最低使用频率的条目。maxsizeNone是无边界缓存看起来省事但缓存条目会一直堆积内存压力会慢慢变大。高并发场景下缓存写入还需要注意线程安全的问题。它只适合“同样输入必然同输出”的纯函数。如果函数依赖全局变量、时间、随机数、外部 IO用lru_cache会得到过期的、错误的缓存结果。另外Python 3.9 增加了functools.cache是lru_cache(maxsizeNone)的简化版本适合不需要淘汰机制的纯函数。但团队如果还在用 Python 3.8 就要注意别随手用。5.3 类方法与 property 的装饰注意事项property、staticmethod、classmethod本质上也是装饰器它们和自定义装饰器叠加时会牵扯顺序问题。一个常见组合是class User: property def name(self): return self._nameproperty返回的是一个描述符对象不是普通函数。如果你再往上叠一个自定义装饰器顺序就非常重要。比如class User: log_call property def name(self): return self._name这里先执行property把name变成描述符再用log_call装饰描述符调用时log_call会把这个描述符当函数调用可能不会得到预期行为。反过来写class User: property log_call def name(self): return self._name先装饰原方法再把装饰后的函数交给property通常更符合预期。所以在叠加内置装饰器和自定义装饰器时我的经验是从下往下读代码先想清楚每个装饰器输出的对象到底是什么类型再决定顺序。6. 多个装饰器叠加时顺序怎么算6.1 按“从下到上”装饰按“从上到下”执行一个函数可以同时挂多个装饰器decorator_a decorator_b def func(): pass等价写法是def func(): pass func decorator_a(decorator_b(func))从执行顺序看先执行decorator_b(func)得到一个新函数然后这个新函数再传给decorator_a。所以装饰阶段是自下而上执行阶段是自上而下。调用func()时首先进入decorator_a生成的 wrapper它在内部调用传给它的那个函数即decorator_b生成的 wrapper最后才调用原始func主体。6.2 从日志、缓存、权限校验的顺序看实际问题假设有个接口函数需要同时做三件事登录校验、结果缓存、调用日志。直觉告诉我应该让权限校验在最外层因为没权限的用户根本不应该触发缓存写入和日志埋点这可能泄露敏感信息或者浪费缓存空间。login_required cache_result log_call def get_profile(user_id): return query_db(user_id)这段代码相当于get_profile login_required(cache_result(log_call(get_profile)))调用时先走login_required再走cache_result再走log_call最后到达原始函数。如果login_required没通过后面的 cache 和 log 都不会执行。反之如果把cache_result放在最外层那么用户 A 请求过的数据可能在未校验登录的情况下直接返回给用户 B这是非常典型的安全事故。6.3 装饰器叠加会影响参数可见性多个装饰器堆叠之后某个装饰器里能看到什么参数、看不到什么参数取决于它挂在哪一层。最外层的装饰器看到的是最原始的调用参数中间层的装饰器看到的是内层 wrapper 暴露出的参数内层的装饰器则可能已经被外层剥掉了一部分参数。比如有个require_role(admin)装饰器它把用户信息解析出来后把user_id注入到函数参数里外层的日志装饰器打印的参数列表就会和内层的实际调用参数不一样。这个问题在排查日志时经常让人迷惑。我通常会约定参数注入类的装饰器放在最内层观察类装饰器放在外层让日志看到的是调用者视角的参数。7. 实战避坑清单从报错到隐蔽问题的排查实录7.1 wrapper 不 return 会丢掉返回值这个前面提过但值得放进避坑清单里重点说。出现症状是被装饰函数在单独调用时返回值正常加上装饰器之后返回值变成了None。排查口诀就一句看 wrapper 里有没有return func(*args, **kwargs)。如果只写了func(*args, **kwargs)调用结果被丢弃wrapper 自然返回None。这个错误在初学装饰器阶段出现频率极高。7.2 闭包延迟绑定循环里生成装饰器时常见的翻车现场最经典的闭包陷阱大家可能见过funcs [] for i in range(3): def f(): return i funcs.append(f) print([f() for f in funcs]) # [2, 2, 2] 而不是 [0, 1, 2]原因是闭包捕获的是i这个变量本身循环结束时i是 2所有函数看到的都是同一个 2。装饰器同样会踩这个坑。看这个场景要批量生成三个不同前缀的日志装饰器很多人的第一版会这样写def build_log_decorators(): decorators [] for level in [INFO, WARN, ERROR]: def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): print(f[{level}] {func.__name__}) return func(*args, **kwargs) return wrapper decorators.append(decorator) return decorators这三个装饰器实际运行时打印出来的 level 大概率全是ERROR。解决办法是在闭包外层绑定当前值最常用的方法是用默认参数def deco(*, levellevel): ...或者用工厂函数把level作为参数传入def make_decorator(level): def decorator(func): ... return decorator只要你看到“循环生成闭包”“批量创建装饰器”“lambda 默认参数”这类关键词第一反应就要检查变量绑定时机。7.3 装饰器执行的时机比很多人以为的更早装饰器语法在函数定义阶段就会执行不在调用阶段执行。这句话要反复体会。也就是说模块被 import 的时候log_call、retry(times5)这些表达式就已经在求值了。如果你写了一个带参数的装饰器工厂工厂内部有一些耗时操作或者打印日志这些输出会在 import 阶段出现而不是在被装饰函数第一次调用时出现。有时候程序一启动就报错但你的函数明明没有被调用过检查点就应该放在装饰器的定义阶段。这也带来一个工程规范装饰器工厂应该只做轻量工作把重量级工作放到 wrapper 内部首次调用时再执行或者用懒加载的方式。我见过有人把数据库连接池初始化放进装饰器工厂里模块一加载就创建连接就算函数一次都没调用也白白占用资源。7.4 类方法装饰时 self 隐身陷阱很多人给类方法加装饰器时容易犯一个错在 wrapper 里按“没有 self”的方式写死调用函数导致运行时报缺少位置参数。def require_login(func): functools.wraps(func) def wrapper(user, *args, **kwargs): if not user.is_authenticated: raise PermissionError(请先登录) return func(user, *args, **kwargs) return wrapper class ProfileView: require_login def get(self, user_id): return fprofile of {user_id}问题在于get是一个绑定方法通过实例调用view.get(1)时解释器自动把view作为第一个参数传进去这个参数通常是self。如果装饰器 wrapper 把第一个参数当作用户对象用了真实self就会被当成用户而真正的用户对象根本没有传进去。解决方式是清楚区分“被装饰的是普通函数还是类方法”。如果装饰器会被用在方法上wrapper 的第一个参数就按*args统一接收不要假设第一个参数是什么或者明确在 wrapper 里以self, *args, **kwargs姿态接收再转发def wrapper(self, *args, **kwargs): ... return func(self, *args, **kwargs)此外类装饰器和staticmethod、classmethod搭配时也要小心先想清楚它们谁先处理了第一个参数千万别拍脑袋。7.5 一个容易被忽略的性能开销问题装饰器每次调用都多了一层函数调用这层开销通常可以忽略但如果你装饰的是一个每秒被调用几十万次的底层小函数影响就会很明显。更麻烦的是如果装饰器内部还做了日志格式化、时间字符串生成、全参数打印这类事情性能损耗会被放大。我习惯在写装饰器时加一个原则装饰器里的日志和额外操作尽量做“廉价版”比如使用logging而不是print并且把参数格式化放在日志真正输出时才执行避免每次都浪费 CPU。对于真正的高频路径宁可把装饰器拆掉直接把需要的逻辑写进函数内部或者用更轻量的方式实现。7.6 线程安全重试、计数器、缓存都要考虑并发装饰器维护的状态变量在多线程环境下可能会出问题。比如前面那个CountCalls类多个线程同时调用同一个实例self.count 1操作不是原子的最终计数可能比实际调用次数少。类似的还有重试装饰器里的次数统计、缓存装饰器里的写入判断。如果装饰器确实要在多线程场景下维护状态加一个threading.Lock或者使用itertools.count配合原子操作更稳妥。不过也不要过度设计只读的缓存用lru_cache基本够用有写入操作且一致性要求高时再考虑加锁。7.7 不要装饰 lambda有的同学喜欢写foo decorator(lambda: ...)语法上合法但调试时出现的问题会让心智成本飙升。lambda 没有__name__没有文档字符串functools.wraps能复制的东西非常有限出错时堆栈里清一色显示lambda根本定位不到具体业务。能用普通函数的时候就别为了省几行代码用 lambda 当装饰对象。8. 一个可以直接抄作业的生产级装饰器模板8.1 支持有参无参、可配置异常类型和重试间隔的 retry综合前面所有避坑点给一个我目前项目里还在用的重试装饰器模板。它支持带参数和不带参数两种用法默认只重试 3 次间隔 0.1 秒可以自定义捕获哪些异常import functools import time def retry(_funcNone, *, times3, delay0.1, exceptions(Exception,)): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(times): try: return func(*args, **kwargs) except exceptions as exc: if attempt times - 1: raise time.sleep(delay) return wrapper if _func is not None: return decorator(_func) return decorator用法示例retry def f1(): ... retry(times5, delay0.2) def f2(): ... retry(times3, exceptions(TimeoutError, ConnectionError)) def f3(): ...注意这里exceptions默认值是一个元组不是列表避免了默认可变参数共享的问题times和delay通过关键字参数传入方便 IDE 提示和代码阅读。实际工程中还有什么可以扩展比如加一个最大重试时间上限在超过某个总耗时后放弃重试比如在重试间隙打印当前失败异常方便排查比如对重试次数做基于指数退避的递增间隔。这些都可以在wrapper内部加业务逻辑不影响整体框架。8.2 什么时候不建议用装饰器装饰器不是万能药。过度使用会带来一个问题函数调用链路变得非常长堆栈里全是嵌套的 wrapper用 pdb 调试还是看异常堆栈都要多翻好几层。如果团队成员对装饰器不熟这种“隐式逻辑”会非常难追踪。我的经验是把这些情况就不要硬上装饰器装饰逻辑只在极少两三个地方用到直接写个普通函数调用比引入装饰器更直白。函数签名对调用方非常重要并且装饰器会修改参数形态此时应该用普通函数或更透明的参数处理方式。装饰器逻辑本身超过几十行还带着一堆配置参数那就说明这部分复杂度值得单独建一个类或者一个模块而不是一个xxx藏在一行。我在项目里常用的一个判断标准是看装饰器被应用了几处。少于三处的直接写函数调用也不丢人超过三处而且模式一致的才值得封装成装饰器。用在正确的地方装饰器会非常优雅滥用起来它会让整个代码库变得像洋葱每一层都让人摸不着头脑。写到这里最后再分享一个小技巧排查装饰器问题最有效的动作是打印类型和__dict__。比如发现函数行为不对先用print(func)看它是不是预期的function对象再看func.__dict__里有没有被人塞入的额外属性。这个办法帮我解决过不少“某处莫名其妙多了个属性”“函数被某个装饰器偷偷替换了”之类的疑难杂症。装饰器的世界本质上就是一个对象不断被包装、替换、修补的过程把每个对象是谁、包装关系是什么看清楚了你就真正掌握它了。