Python 有两个语法特性,第一次见到可能会觉得有点「魔法」:装饰器(@ 开头的那个)和 with 语句。它们在标准库和第三方库里到处都是,不了解的话看别人代码会很懵。
这篇就来把它们讲明白。装饰器的核心是闭包,with 的核心是上一篇讲的魔术方法,理解了原理之后你会发现它们其实很简单。
闭包
在讲装饰器之前,需要先理解一个概念:闭包(Closure)。
我们在 函数 那篇讲过,Python 的函数是一等公民,可以当参数传给其他函数(比如 sorted(key=lambda ...))。其实函数不仅能当参数,还能当返回值。
注意看:make_greeter("你好") 执行完之后,按理说局部变量 greeting 应该消失了。但 hello("Alice") 调用时,它还是能访问到 greeting 的值 "你好"。
这就是闭包:内部函数引用了外部函数的变量,即使外部函数已经执行完毕,这些变量依然被保留着。内部函数「记住」了它被创建时的环境。
再来一个更实际的例子:
每次调用 make_counter() 都会创建一份独立的 count 变量,所以两个计数器互不干扰。这里用了一个小技巧:把 count 存在列表里而不是直接用 count = 0。
原因是 Python 看到 count += 1(也就是 count = count + 1)时,会认为你在创建一个名为 count 的局部变量,结果右边的 count 还没赋值就被读取了,直接报 UnboundLocalError。用列表 count[0] += 1 就没这个问题,因为你没有给 count 本身重新赋值,只是修改了列表内部的元素。
Python 3 还提供了更直接的解决方案,用 nonlocal 关键字声明「这个变量不是局部的」:
# 用 nonlocal 就不需要列表技巧了
# def counter():
# nonlocal count
# count += 1 # 现在可以直接修改了装饰器
理解了闭包之后,装饰器就好讲了。
假设你有一堆函数,想在每个函数执行前后打印日志。最笨的办法是在每个函数里手动加 print,但这样太啰嗦了。装饰器的思路是:写一个「包装函数」,把原函数包一层,在调用前后加上额外的逻辑。
看明白了吗?timer 接收一个函数 func,返回一个新函数 wrapper。wrapper 在调用 func 前后加了计时逻辑。最后 slow_add = timer(slow_add) 把原函数替换成包装后的版本。
这就是装饰器的全部原理。slow_add = timer(slow_add) 这行代码太常见了,Python 提供了 @ 语法糖来简化它:
@timer 写在函数定义上方,Python 在定义函数后自动执行 slow_add = timer(slow_add)。效果和手动写一模一样,但看起来更清晰:一眼就能看出这个函数被 timer 装饰了。
functools.wraps
装饰器有个小问题:包装之后,原函数的名字和文档字符串(docstring,写在函数第一行的三引号字符串)会丢失。
因为 hello 实际上已经被替换成了 wrapper,所以 __name__ 和 __doc__ 都是 wrapper 的。这在调试的时候会很困惑。
解决方法很简单,用 functools.wraps 装饰 wrapper:
你可能注意到 @functools.wraps(func) 带了参数,和前面的 @timer 形式不太一样。这其实是「带参数的装饰器」,后面会专门讲。这里只需要知道:写装饰器时在 wrapper 上方加上这行,就能把原函数的 __name__、__doc__ 等属性复制过来。这是个好习惯。
实用装饰器
了解了装饰器的原理,来看几个 Python 自带的实用装饰器。
@functools.lru_cache:记忆化
这个装饰器会缓存函数的返回值,相同的参数只计算一次。刷算法题时用它做记忆化搜索非常方便。
不加缓存的斐波那契递归是指数级复杂度,fib(30) 要递归几百万次。加了 @lru_cache 之后,每个 n 只算一次,后续直接从缓存取结果,瞬间完成。
maxsize=None 表示缓存不限大小。如果设成一个数字比如 maxsize=128,那最多缓存 128 个结果,超了就用 LRU(最近最少使用)策略淘汰旧的。
@staticmethod 和 @classmethod
这两个用在类里面,调整方法和类的关系。
@staticmethod 就是一个挂在类下面的普通函数,不接收 self 或 cls。适合跟类相关但不需要访问实例或类属性的工具函数。
@classmethod 的第一个参数 cls 是类本身(就像普通方法的 self 是实例),可以访问类属性。
用 cls.pi 而不是直接写 MathHelper.pi,好处是如果有子类继承了这个方法,cls 会自动指向子类。
带参数的装饰器
前面的装饰器都不接收参数。如果你想让装饰器本身也能传参数,需要再套一层函数:
@repeat(3) 实际上分两步执行:先调用 repeat(3) 返回 decorator,然后用 decorator 装饰 say_hello。
所以带参数的装饰器就是三层嵌套函数:最外层接收装饰器的参数,中间层接收被装饰的函数,最内层是实际执行的包装函数。
嵌套三层确实不太好看,但这是 Python 装饰器的固定套路。写多了就习惯了。
with 语句
接下来讲 with 语句。先简单介绍一下文件操作:open(path, mode) 是 Python 打开文件的内置函数,"w" 表示写模式(write),"r" 表示读模式(read)。
打开文件后要记得关闭,否则可能丢失数据或占用系统资源。with 语句可以帮你自动关闭文件。
with open(...) as f: 做了两件事:进入代码块时打开文件并赋值给 f,离开代码块时自动关闭文件。不管代码块里是正常执行完毕还是抛了异常,文件都会被关闭。
如果不用 with,你就得自己手动关闭:
f = open("data.txt", "r")
try:
content = f.read()
finally:
# finally 块中的代码无论是否发生异常都会执行
f.close()with 语句帮你省掉了 try/finally 和手动 close(),代码更简洁,也更不容易忘记释放资源。
with 的原理
with 语句并不是只能用于文件操作,任何实现了 __enter__ 和 __exit__ 魔术方法的对象都可以用 with。
当你写 with obj as x: 时,Python 先调用 obj.__enter__(),把返回值赋给 x,然后执行 with 代码块。代码块结束后,不管是否出错,都会调用 obj.__exit__() 做清理。
来手动实现一个:
__exit__ 必须接收 exc_type、exc_val、exc_tb 这三个参数,这是 Python 的规定。
大部分情况下你不需要关心它们(没有异常时都是 None),只要记住最后返回 False 就行。返回 False 表示不拦截异常,让它正常传播。
再来看一个更实际的例子,临时修改某个状态,用完自动恢复:
这个模式很常见:进入时保存旧状态、设置新状态,离开时恢复旧状态。数据库事务、锁的获取释放、临时环境变量设置等都用类似的套路。
contextlib.contextmanager
每次都要写一个类、实现 __enter__ 和 __exit__,有点麻烦。Python 的 contextlib 模块提供了一个装饰器,让你用更简洁的方式写上下文管理器。
这里会用到 yield 关键字。你可以暂时把它理解为一个特殊的暂停点:执行到 yield 时函数会暂停,把控制权交给 with 代码块;等 with 块里的代码执行完,函数再从 yield 的下一行继续执行。
yield 把函数分成两半:yield 之前的代码在进入 with 块时执行(相当于 __enter__),yield 之后的代码在离开 with 块时执行(相当于 __exit__)。比写一个类简洁多了。
再来一个实际的例子,临时修改列表并自动恢复:
两个示例都用了 try/finally 包裹 yield,确保即使 with 块里抛了异常,清理代码也一定会执行。这是写 contextmanager 的标准做法。
小结
装饰器本质上就是「用一个函数包装另一个函数」,@ 是语法糖。理解闭包之后,装饰器就没有任何神秘的了。实际中最常用的是 @functools.lru_cache(记忆化缓存)和 @staticmethod/@classmethod。写自己的装饰器时记得加 @functools.wraps 保留原函数信息。
with 语句用于自动管理资源,进入时获取、离开时释放,不管是否出错都能保证清理。底层靠 __enter__ 和 __exit__ 魔术方法驱动。想快速写一个上下文管理器,用 @contextmanager 装饰器配合 yield 最方便。
这两篇讲的内容在刷算法题时用得不多,但在阅读和编写实际项目代码时会频繁遇到。了解了原理之后,看到别人的代码就不会懵了。