Python装饰器、闭包与作用域理解高级语法的底层逻辑如果你在学 Python 的路上问任何一个有经验的开发者Python 里最难理解的概念是什么答案大概率是装饰器。它几乎成了 Python 进阶的成人礼——没理解装饰器之前觉得它像魔法理解之后发现它只是把函数是一等公民这个特性用到了极致。但要理解装饰器你必须先理解闭包。要理解闭包你必须先理解作用域。所以这篇的顺序是作用域 → 闭包 → 装饰器。三个概念像三块搭在一起的积木底下的不牢固上面的就会晃动。一、作用域与闭包作用域变量在哪里可见LEGB 规则Python 查找变量时按四个层级从内向外搜索Local → Enclosing → Global → Built-in。# Built-inPython 内置的名字xglobal# Global模块级别的全局变量defouter():xenclosing# Enclosing外层函数的变量definner():xlocal# Local当前函数的局部变量print(x)# 输出 local ——最近的原则inner()outer()这个规则决定了当多个同名变量存在于不同层级时哪个会被选中——永远是最内层的那个。nonlocal修改外层函数的变量defouter():count0definner():nonlocalcount# 声明 count 是外层函数的变量count1# 没有 nonlocal 的话这行会创建新的局部变量 countreturncountreturninner counterouter()print(counter())# 1print(counter())# 2print(counter())# 3没有nonlocalinner里对count的赋值会让 Python 认为你想创建一个新的局部变量count而不是修改外层的那个——结果就是UnboundLocalError因为你还没有给局部变量赋过值就尝试。nonlocal告诉 Python“去 Enclosing 层级找这个变量修改它”。global修改模块级别的全局变量total_calls0deftrack_calls():globaltotal_calls total_calls1returntotal_callsprint(track_calls())# 1print(track_calls())# 2print(total_calls)# 2global应该少用。函数通过参数接收输入、通过 return 返回结果是理想状态。频繁使用global让数据流变得难以追踪。闭包函数记住了它出生的环境当一个内部函数引用了外部函数的变量并且外部函数把内部函数返回了出去——这就形成了一个闭包。defmake_multiplier(factor):defmultiply(number):returnnumber*factor# multiply 引用了 make_multiplier 的变量 factorreturnmultiply# 返回内部函数doublemake_multiplier(2)triplemake_multiplier(3)print(double(5))# 10print(triple(5))# 15double是make_multiplier(2)返回的multiply函数。这个函数记住了创建它时factor的值是 2。即使make_multiplier已经执行完毕返回了factor 2这个绑定依然保存在闭包里。triple同理但它记住的是factor 3。闭包之所以能记住是因为 Python 在创建内部函数时把外部函数的变量环境一起打包了进去。这就像你离家去上大学时带了一张全家福——家已经不在身边了但照片帮你记住了家人的样子。闭包的实际应用# 应用一创建带配置的函数defmake_tag(tag_name):创建 HTML 标签生成器defwrap(content):returnf{tag_name}{content}/{tag_name}returnwrap h1make_tag(h1)pmake_tag(p)print(h1(欢迎))# h1欢迎/h1print(p(这是一段文字))# p这是一段文字/p# 应用二函数工厂——创建一系列相关函数defpower_factory(exponent):创建 n 次方计算函数returnlambdabase:base**exponent squarepower_factory(2)cubepower_factory(3)print(square(5))# 25print(cube(5))# 125图16-1 LEGB 作用域规则Local → Enclosing → Global → Built-in 的变量查找顺序闭包利用 Enclosing 层实现记忆。二、装饰器装饰器在不修改原函数的条件下增强它好了有了闭包的基础装饰器就不再神秘了。装饰器本质上就是一个接收函数、返回新函数的函数。它的核心价值在于给函数增加功能但不用修改原函数的代码。手写一个最简单的装饰器deflogger(func):装饰器打印函数的调用信息defwrapper(*args,**kwargs):print(f[LOG] 调用{func.__name__}参数{args}{kwargs})resultfunc(*args,**kwargs)# 调用原函数print(f[LOG]{func.__name__}返回{result})returnresultreturnwrapperlogger# 使用装饰器defadd(a,b):returnab resultadd(3,5)# [LOG] 调用 add参数(3, 5) {}# [LOG] add 返回8logger等价于add logger(add)——把add函数传给logger用返回的wrapper函数替换原来的add。所以当你调用add(3, 5)时实际执行的是wrapper(3, 5)。用 functools.wraps 保留原函数的元信息上面的装饰器有一个问题print(add.__name__)# wrapper ——不是 addprint(add.__doc__)# None ——原函数的文档字符串丢了functools.wraps解决了这个问题fromfunctoolsimportwrapsdeflogger(func):wraps(func)# 把原函数的元信息复制到 wrapper 上defwrapper(*args,**kwargs):print(f[LOG] 调用{func.__name__})returnfunc(*args,**kwargs)returnwrapperloggerdefadd(a,b):返回 a b 的结果returnabprint(add.__name__)# addprint(add.__doc__)# 返回 a b 的结果始终在装饰器里使用wraps(func)——这个习惯能避免很多微妙的 bug特别是在使用调试工具和文档生成工具时。带参数的装饰器有时候装饰器本身也需要参数——比如计时装饰器需要指定计时单位fromfunctoolsimportwrapsimporttimedeftimer(unit秒):装饰器工厂接收参数返回装饰器defdecorator(func):wraps(func)defwrapper(*args,**kwargs):starttime.perf_counter()resultfunc(*args,**kwargs)elapsedtime.perf_counter()-startifunit毫秒:elapsed*1000print(f{func.__name__}耗时{elapsed:.4f}{unit})returnresultreturnwrapperreturndecoratortimer(unit毫秒)defslow_function(n):returnsum(range(n))slow_function(1000000)# slow_function 耗时15.2345 毫秒这里有三层嵌套函数timer是装饰器工厂接收参数→返回decorator真正的装饰器→返回wrapper包装后的函数。虽然嵌套多了一层但逻辑和前面一样——只是把参数传给了最外层的工厂函数。多个装饰器的叠加decorator_adecorator_bdefmy_func():pass# 等价于my_funcdecorator_a(decorator_b(my_func))装饰器从下往上执行——先应用离函数最近的装饰器。如果你需要记录日志又需要计时可以叠加两个装饰器各司其职。图16-2 装饰器包装流程原函数 func →被 wrapper 包装→ 调用时先执行额外逻辑 → 调用原函数 → 返回结果。作用域的深层理解——为什么 Python 选择 LEGB 而不是其他规则很多语言的变量作用域是块级的——在{}花括号内声明的变量只在花括号里有效。Java、C、JavaScriptlet/const都是这样。Python 选择了一条不同的路——变量作用域是函数级的。在一个函数内任何地方赋值的变量在整个函数里都可见。这个选择导致了nonlocal和global关键字的存在——因为如果你在嵌套函数里试图给外部变量赋值Python 会认为你想创建新的局部变量因为这符合函数级作用域的规则而不是修改外部变量。nonlocal是你告诉 Python不对我要修改的是外层的那个的方式。这个设计有利有。好处是你不需要在每个 if/for 块结束后考虑变量的生命周期代码更简洁。缺点是如果你不小心在嵌套函数中忘写了nonlocalPython 不会报错——它会安静地创建一个新的局部变量给你完全不符合预期的结果。理解 LEGB 规则和nonlocal的行为是避免这类 bug 的唯一方法。闭包的进阶视角——它是轻量级的对象如果你学过面向对象可能会注意到闭包和类之间的相似性。闭包记住了外部函数的变量状态——这和一个对象记住了自己的属性本质上是一样的。实际上在很多场景中闭包和类可以互换使用# 用类实现计数器classCounter:def__init__(self,start0):self.countstartdef__call__(self):self.count1returnself.count# 用闭包实现计数器defmake_counter(start0):countstartdefcounter():nonlocalcount count1returncountreturncounter# 两者使用方式完全相同c1Counter(10)c2make_counter(10)print(c1())# 11print(c2())# 11闭包的优势是代码更短——一个函数就搞定了。类的优势是更复杂的状态管理更方便——你可以加更多方法、用继承和组合。两者不是对立关系而是工具箱里的两件不同工具。选择取决于问题的复杂度。装饰器的实战应用应用一函数耗时统计fromfunctoolsimportwrapsimporttimedeftimeit(func):wraps(func)defwrapper(*args,**kwargs):starttime.perf_counter()resultfunc(*args,**kwargs)elapsedtime.perf_counter()-startprint(f⏱{func.__name__}耗时{elapsed:.6f}秒)returnresultreturnwrappertimeitdeffibonacci(n):a,b0,1for_inrange(n):a,bb,abreturna应用二重试机制importtimedefretry(max_attempts3,delay1):defdecorator(func):wraps(func)defwrapper(*args,**kwargs):forattemptinrange(1,max_attempts1):try:returnfunc(*args,**kwargs)exceptExceptionase:ifattemptmax_attempts:raise# 最后一次也失败抛出异常print(f第{attempt}次失败{e}{delay}秒后重试...)time.sleep(delay)returnNonereturnwrapperreturndecoratorretry(max_attempts3,delay2)defunstable_network_call():# ... 模拟不稳定的网络请求pass应用三权限校验defrequire_admin(func):wraps(func)defwrapper(user,*args,**kwargs):ifnotuser.get(is_admin):raisePermissionError(需要管理员权限)returnfunc(user,*args,**kwargs)returnwrapperrequire_admindefdelete_user(admin_user,user_id):print(f用户{user_id}已被{admin_user[name]}删除)# admin {name: Admin, is_admin: True, role: admin}# delete_user(admin, U042) # OK应用四缓存结果defmemoize(func):缓存函数调用结果相同参数不重复计算cache{}wraps(func)defwrapper(*args):ifargsnotincache:cache[args]func(*args)returncache[args]returnwrappermemoizedeffib(n):计算第 n 个斐波那契数递归版ifn2:returnnreturnfib(n-1)fib(n-2)print(fib(100))# 有了缓存瞬间算出结果没有memoize装饰器递归版的fib(100)算到世界末日也算不完——它的时间复杂度是指数级的。加上缓存后每个n的值只计算一次时间复杂度降到了线性的 O(n)。这就是装饰器的力量——既直接改善性能又不改变递归算法原本清晰的表达。图16-3 装饰器本质接收函数返回新函数。常见用途包括计时、日志、权限检查、缓存、重试、输入校验。三、进阶与总结闭包的陷阱和注意事项陷阱循环中创建闭包# 错误所有闭包引用了同一个变量 ifunctions[]foriinrange(3):functions.append(lambda:i)# lambda 引用的是变量 i不是值print([f()forfinfunctions])# [2, 2, 2] ——不是 [0, 1, 2]原因lambda 在创建时没有冻结i的值——它只是记住了这个变量名。当循环结束后i 2所有三个 lambda 看到的都是同一个i 2。# 修复用默认参数快照当前值functions[]foriinrange(3):functions.append(lambdaii:i)# ii 在定义时就把值捕获了print([f()forfinfunctions])# [0, 1, 2]这是经典的 Python 面试题也是很多初学者被坑的地方。了解了闭包的变量引用延迟绑定特性你就不会再被它坑到。装饰器的高级模式——装饰器类除了函数装饰器Python 还支持用类来做装饰器。装饰器类的核心是__call__方法——当实例被当成函数来调用时Python 会调用__call__fromfunctoolsimportwrapsclassCountCalls:统计一个函数被调用了多少次用类做装饰器def__init__(self,func):wraps(func)(self)self.count0def__call__(self,*args,**kwargs):self.count1print(f{self.__wrapped__.__name__}被调用了{self.count}次)returnself.__wrapped__(*args,**kwargs)CountCallsdefgreet(name):returnf你好{name}print(greet(小明))# greet 被调用了 1 次 你好小明print(greet(小红))# greet 被调用了 2 次 你好小红装饰器类的优势在于你可以用self在多次调用之间保存状态这里是self.count。函数装饰器要做到同样的效果需要用闭包 nonlocal而类装饰器在状态管理上更直观——所有状态都是self的属性。当你需要一个装饰器同时完成计时、计数、缓存等多种功能时装饰器类比多层嵌套的函数装饰器更清晰。装饰器在现实世界中的应用——Django 和 Flask 的例子如果你未来学习 Web 开发会在 Django 和 Flask 中频繁看到装饰器的身影。Flask 的路由系统就是一个绝佳的例子app.route(/users/id)这行装饰器告诉 Flask当浏览器访问 /users/123 这个 URL 时请调用下面这个函数。Django 用login_required来保护需要登录才能访问的页面。FastAPI 用装饰器来声明 API 端点、参数校验、响应模型。所有这些框架都利用装饰器的核心能力——在不修改原函数的前提下给函数添加额外的行为和元信息。你不需要现在就理解这些框架的具体用法。但当未来的某一天你在 Django 或 Flask 中自如地用着各种装饰器时回想一下这一篇——你从手写一个 logger 装饰器开始最终走到了理解整个 Web 框架的核心机制。这就是知识积累的复利效应。动手练习装饰器执行顺序实验写三个装饰器decorator_a、decorator_b、decorator_c每个在被调用时打印自己的名字。把它们叠加在一个函数上decorator_a decorator_b decorator_c def test(): pass然后调用test()。观察打印顺序——装饰器从下往上应用最靠近函数的先执行理解这个规则对于调试多层装饰器至关重要。这个实验只需要十分钟但它会帮你彻底理解装饰器的叠加机制。日志装饰器写一个log_calls装饰器每次被装饰的函数被调用时打印函数名、传入的参数和返回的结果。支持日志输出到文件的可选参数。执行时间限制写一个timeout(seconds)装饰器如果函数执行超过指定秒数则抛出TimeoutError。类型校验装饰器写一个装饰器validate_types检查传入参数的类型是否匹配函数注解。例如def add(a: int, b: int) - int:应该拒绝非整数参数。闭包计数器用闭包实现一个计数器函数每次调用返回递增的数字从 1 开始但不会使用全局变量。装饰器的滥用——什么时候不该用装饰器很酷但它会隐藏函数的签名和文档。过度使用装饰器会让代码的调用链变得难以追踪。一个简单的判断标准如果装饰器让一个简单的函数多了三层缩进和两个wraps考虑是否可以用显式的函数调用替代。装饰器最适合的场景是——横切关注点日志、计时、权限、缓存即同一个逻辑需要在很多不同的函数上反复应用。如果某个装饰器只用在一两个函数上直接写进函数体可能更清晰。写一个单例装饰器单例模式保证一个类只存在一个实例。写一个singleton装饰器——第一个调用MyClass()时创建并缓存实例后续调用返回同一个缓存实例。提示用闭包或装饰器的内部字典来存储已创建的实例。这个模式在数据库连接、配置管理、日志系统中非常常用。读懂标准库中的装饰器打开 Python 标准库源码或在交互模式中import functools; help(functools.lru_cache)读懂lru_cache的实现思路。它和本文中memoize的核心逻辑几乎相同但增加了缓存容量限制LRU Least Recently Used最近最少使用的缓存项会被自动淘汰。能够读懂标准库中的装饰器源码说明你已经真正掌握了这个Python 进阶的成人礼。如果一次没看懂也没关系——lru_cache的源码涉及一些高级技巧——重要的是你已经具备了通过读源码来学习的能力。这个能力本身比任何一个具体的装饰器知识点都重要。事实上整个 Python 社区最强大的学习资源不是教程也不是文档——是标准库和知名开源项目的源码。当你不再害怕打开一个陌生库的__init__.py一探究竟时你就从一个会用库的人变成了一个能理解库的人。这两者的职业发展空间是完全不同的。会用库的人可以完成工作任务“能理解库的人可以设计系统架构、解决别人解决不了的问题、在技术选型时做出更明智的判断。Python 社区以代码可读性著称——很多知名库的源码写得像教程一样清晰。利用这个优势养成读源码的习惯你会进步得比只看教程的人快得多。Python 的核心理念之一就是可读性很重要”——这不仅仅是指你自己写的代码要好读也是指 Python 生态中优秀的库和框架它们的源码本身就是最好的学习材料。装饰器——Python 的元编程入门装饰器之所以让很多人觉得难是因为它涉及操作函数的函数——你在写代码来操作代码本身。这个领域叫做元编程在其他语言里往往需要宏、反射、字节码操作等复杂手段但在 Python 里一个符号就搞定了。更重要的是理解为什么需要元编程。假设你在维护一个大型项目你需要给 50 个 API 接口函数都加上检查用户权限的逻辑。如果没有装饰器你会打开 50 个函数在每个里面加 3 行相同的代码。这违反了 DRY 原则Don’t Repeat Yourself。有了装饰器你只在装饰器里写一次然后在 50 个函数定义前各加一行require_auth。修改权限检查逻辑时改一个地方就行。这个在不修改原函数的前提下增强它的能力的思维模式是装饰器价值的核心。日志、计时、缓存、权限、重试、参数校验——这些横切关注点cross-cutting concerns通过装饰器来实现比你手动在每个函数里复制粘贴要优雅一万倍。理解装饰器可能需要反复回来看几次。没关系几乎所有 Python 开发者都有某一天突然就看懂了装饰器的经历。从def到你已经进入了 Python 的高级领域。要点回顾LEGB 规则决定了变量查找顺序局部 → 外层 → 全局 → 内置闭包 内部函数援引了外部函数的变量并且外部函数返回了内部函数装饰器 接收函数、返回新函数的函数本质是闭包的高级应用decorator等价于func decorator(func)始终用functools.wraps(func)保留原函数的元信息装饰器叠加从下往上执行循环中创建闭包要注意延迟绑定陷阱——用默认参数快照当前值下篇预告下一篇我们进入 Python 常用标准库——datetime、pathlib、json、random 等让你的编程工具箱更加丰富。