Python装饰器、闭包与作用域:理解高级语法的底层逻辑
如果你在学 Python 的路上问任何一个有经验的开发者"Python 里最难理解的概念是什么",答案大概率是装饰器。它几乎成了 Python 进阶的"成人礼"——没理解装饰器之前觉得它像魔法,理解之后发现它只是把"函数是一等公民"这个特性用到了极致。
但要理解装饰器,你必须先理解闭包。要理解闭包,你必须先理解作用域。所以这篇的顺序是:作用域 → 闭包 → 装饰器。三个概念像三块搭在一起的积木,底下的不牢固,上面的就会晃动。
一、作用域与闭包
作用域:变量在哪里可见
LEGB 规则
Python 查找变量时按四个层级从内向外搜索:Local → Enclosing → Global → Built-in。
# Built-in:Python 内置的名字x="global"# Global:模块级别的全局变量defouter():x="enclosing"# Enclosing:外层函数的变量definner():x="local"# Local:当前函数的局部变量print(x)# 输出 "local" ——最近的原则inner()outer()这个规则决定了"当多个同名变量存在于不同层级时,哪个会被选中"——永远是最内层的那个。
nonlocal:修改外层函数的变量
defouter():count=0definner():nonlocalcount# 声明 count 是外层函数的变量count+=1# 没有 nonlocal 的话,这行会创建新的局部变量 countreturncountreturninner counter=outer()print(counter())# 1print(counter())# 2print(counter())# 3没有nonlocal,inner里对count的赋值会让 Python 认为你想创建一个新的局部变量count,而不是修改外层的那个——结果就是UnboundLocalError(因为你还没有给局部变量赋过值就尝试+=)。nonlocal告诉 Python:“去 Enclosing 层级找这个变量,修改它”。
global:修改模块级别的全局变量
total_calls=0deftrack_calls():globaltotal_calls total_calls+=1returntotal_callsprint(track_calls())# 1print(track_calls())# 2print(total_calls)# 2global应该少用。函数通过参数接收输入、通过 return 返回结果是理想状态。频繁使用global让数据流变得难以追踪。
闭包:函数记住了它出生的环境
当一个内部函数引用了外部函数的变量,并且外部函数把内部函数返回了出去——这就形成了一个闭包。
defmake_multiplier(factor):defmultiply(number):returnnumber*factor# multiply 引用了 make_multiplier 的变量 factorreturnmultiply# 返回内部函数double=make_multiplier(2)triple=make_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 h1=make_tag("h1")p=make_tag("p")print(h1("欢迎"))# <h1>欢迎</h1>print(p("这是一段文字"))# <p>这是一段文字</p># 应用二:函数工厂——创建一系列相关函数defpower_factory(exponent):"""创建 n 次方计算函数"""returnlambdabase:base**exponent square=power_factory(2)cube=power_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}")result=func(*args,**kwargs)# 调用原函数print(f"[LOG]{func.__name__}返回:{result}")returnresultreturnwrapper@logger# 使用装饰器defadd(a,b):returna+b result=add(3,5)# [LOG] 调用 add,参数:(3, 5) {}# [LOG] add 返回:8@logger等价于add = logger(add)——把add函数传给logger,用返回的wrapper函数替换原来的add。所以当你调用add(3, 5)时,实际执行的是wrapper(3, 5)。
用 functools.wraps 保留原函数的元信息
上面的装饰器有一个问题:
print(add.__name__)# "wrapper" ——不是 "add"!print(add.__doc__)# None ——原函数的文档字符串丢了!@functools.wraps解决了这个问题:
fromfunctoolsimportwrapsdeflogger(func):@wraps(func)# 把原函数的元信息复制到 wrapper 上defwrapper(*args,**kwargs):print(f"[LOG] 调用{func.__name__}")returnfunc(*args,**kwargs)returnwrapper@loggerdefadd(a,b):"""返回 a + b 的结果"""returna+bprint(add.__name__)# "add"print(add.__doc__)# "返回 a + b 的结果"始终在装饰器里使用@wraps(func)——这个习惯能避免很多微妙的 bug,特别是在使用调试工具和文档生成工具时。
带参数的装饰器
有时候装饰器本身也需要参数——比如计时装饰器需要指定计时单位:
fromfunctoolsimportwrapsimporttimedeftimer(unit="秒"):"""装饰器工厂:接收参数,返回装饰器"""defdecorator(func):@wraps(func)defwrapper(*args,**kwargs):start=time.perf_counter()result=func(*args,**kwargs)elapsed=time.perf_counter()-startifunit=="毫秒":elapsed*=1000print(f"{func.__name__}耗时:{elapsed:.4f}{unit}")returnresultreturnwrapperreturndecorator@timer(unit="毫秒")defslow_function(n):returnsum(range(n))slow_function(1000000)# slow_function 耗时:15.2345 毫秒这里有三层嵌套函数:timer是装饰器工厂(接收参数)→返回decorator(真正的装饰器)→返回wrapper(包装后的函数)。虽然嵌套多了一层,但逻辑和前面一样——只是把"参数"传给了最外层的工厂函数。
多个装饰器的叠加
@decorator_a@decorator_bdefmy_func():pass# 等价于my_func=decorator_a(decorator_b(my_func))装饰器从下往上执行——先应用离函数最近的装饰器。如果你需要记录日志又需要计时,可以叠加两个装饰器,各司其职。
图16-2 装饰器包装流程:原函数 func →被 wrapper 包装→ 调用时先执行额外逻辑 → 调用原函数 → 返回结果。
作用域的深层理解——为什么 Python 选择 LEGB 而不是其他规则
很多语言的变量作用域是"块级"的——在{}花括号内声明的变量只在花括号里有效。Java、C、JavaScript(let/const)都是这样。Python 选择了一条不同的路——变量作用域是"函数级"的。在一个函数内任何地方赋值的变量,在整个函数里都可见。
这个选择导致了nonlocal和global关键字的存在——因为如果你在嵌套函数里试图给外部变量赋值,Python 会认为你想创建新的局部变量(因为这符合函数级作用域的规则),而不是修改外部变量。nonlocal是你告诉 Python"不对,我要修改的是外层的那个"的方式。
这个设计有利有��。好处是:你不需要在每个 if/for 块结束后考虑变量的生命周期,代码更简洁。缺点是:如果你不小心在嵌套函数中忘写了nonlocal,Python 不会报错——它会安静地创建一个新的局部变量,给你完全不符合预期的结果。理解 LEGB 规则和nonlocal的行为,是避免这类 bug 的唯一方法。
闭包的进阶视角——它是轻量级的对象
如果你学过面向对象,可能会注意到闭包和类之间的相似性。闭包"记住"了外部函数的变量状态——这和一个对象"记住"了自己的属性本质上是一样的。实际上,在很多场景中闭包和类可以互换使用:
# 用类实现计数器classCounter:def__init__(self,start=0):self.count=startdef__call__(self):self.count+=1returnself.count# 用闭包实现计数器defmake_counter(start=0):count=startdefcounter():nonlocalcount count+=1returncountreturncounter# 两者使用方式完全相同c1=Counter(10)c2=make_counter(10)print(c1())# 11print(c2())# 11闭包的优势是代码更短——一个函数就搞定了。类的优势是更复杂的状态管理更方便——你可以加更多方法、用继承和组合。两者不是对立关系,而是工具箱里的两件不同工具。选择取决于问题的复杂度。
装饰器的实战应用
应用一:函数耗时统计
fromfunctoolsimportwrapsimporttimedeftimeit(func):@wraps(func)defwrapper(*args,**kwargs):start=time.perf_counter()result=func(*args,**kwargs)elapsed=time.perf_counter()-startprint(f"⏱{func.__name__}耗时{elapsed:.6f}秒")returnresultreturnwrapper@timeitdeffibonacci(n):a,b=0,1for_inrange(n):a,b=b,a+breturna应用二:重试机制
importtimedefretry(max_attempts=3,delay=1):defdecorator(func):@wraps(func)defwrapper(*args,**kwargs):forattemptinrange(1,max_attempts+1):try:returnfunc(*args,**kwargs)exceptExceptionase:ifattempt==max_attempts:raise# 最后一次也失败,抛出异常print(f"第{attempt}次失败({e}),{delay}秒后重试...")time.sleep(delay)returnNonereturnwrapperreturndecorator@retry(max_attempts=3,delay=2)defunstable_network_call():# ... 模拟不稳定的网络请求pass应用三:权限校验
defrequire_admin(func):@wraps(func)defwrapper(user,*args,**kwargs):ifnotuser.get("is_admin"):raisePermissionError("需要管理员权限")returnfunc(user,*args,**kwargs)returnwrapper@require_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]returnwrapper@memoizedeffib(n):"""计算第 n 个斐波那契数(递归版)"""ifn<2: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(lambdai=i:i)# i=i 在定义时就把值捕获了print([f()forfinfunctions])# [0, 1, 2]这是经典的 Python 面试题,也是很多初学者被坑的地方。了解了闭包的"变量引用延迟绑定"特性,你就不会再被它坑到。
装饰器的高级模式——装饰器类
除了函数装饰器,Python 还支持用类来做装饰器。装饰器类的核心是__call__方法——当实例被当成函数来调用时,Python 会调用__call__:
fromfunctoolsimportwrapsclassCountCalls:"""统计一个函数被调用了多少次(用类做装饰器)"""def__init__(self,func):wraps(func)(self)self.count=0def__call__(self,*args,**kwargs):self.count+=1print(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 等,让你的编程工具箱更加丰富。