yield、with 和 @contextmanager 经常一起出现,但它们各自解决的问题不同:
| 概念 | 作用 |
|---|---|
yield |
向外产出一个值,并暂停生成器,保留执行位置和局部状态 |
with |
按上下文管理协议执行进入、业务处理和退出逻辑 |
@contextmanager |
将生成器函数包装成上下文管理器工厂,用函数写法组织准备与清理 |
本文从几个可以运行的例子出发,先看生成器如何暂停和恢复,再看这些能力怎样用于资源管理。所有示例只使用 Python 标准库,建议按文章顺序在同一解释器或脚本中执行。
1. yield:交出一个值,把执行进度留在原地
先看一个有两个 yield 的函数:
def demo():
print("执行第一段")
yield 100
print("执行第二段")
yield 200
print("执行结束")
g = demo()
a = next(g)
print(a)
b = next(g)
print(b)
for item in g:
print(item)
输出:
执行第一段
100
执行第二段
200
执行结束
函数体中包含 yield,这个函数就是生成器函数。调用 demo() 会得到生成器对象,函数体此时还没有开始执行。
| 操作 | 生成器的行为 | 调用者得到什么 |
|---|---|---|
g = demo() |
创建生成器,尚未执行函数体 | 生成器对象 |
第一次 next(g) |
打印“执行第一段”,暂停在 yield 100 |
100 |
第二次 next(g) |
从暂停处继续,打印“执行第二段”,暂停在 yield 200 |
200 |
for item in g |
再次恢复,打印“执行结束”,随后结束 | 没有新的元素 |
为什么最后的 for 没有打印 100 和 200?因为这两个值已经被前面的 next() 取走了。生成器会保留当前位置,遍历同一个生成器不会从头开始。
最后的“执行结束”来自 demo() 内部,不是来自循环体中的 print(item)。生成器结束时会发出 StopIteration;for 自动处理这个结束信号,所以没有报错。如果结束后手动调用 next(g),就需要处理它:
try:
next(g)
except StopIteration:
print("没有下一个值了")
输出:
没有下一个值了
return 会结束本次函数执行;yield 会暂停生成器,后续仍可恢复。这种按需产出数据的能力,常用于逐条处理记录或逐批读取数据。
2. res = yield 4:产出的值和接收的值是两回事
下面的代码很容易让人误以为 res 会被赋值为 4:
def foo():
print("starting...")
while True:
res = yield 4
print("res:", res)
g = foo()
print(next(g))
print("*" * 20)
print(next(g))
实际输出:
starting...
4
********************
res: None
4
res = yield 4 包含两个发生在不同时间的动作:
- 执行到
yield 4时,向调用者交出4,生成器暂停,赋值尚未完成。 - 下一次恢复生成器时,
yield表达式接收到恢复时传入的值,这个值才赋给res。
第一次 next(g) 的过程是:打印 starting...,交出 4,然后停在赋值语句中间。
第二次 next(g) 恢复生成器时,相当于使用 g.send(None)。因此 res 得到 None,打印 res: None。接着进入下一轮循环,再次遇到 yield 4,所以这次 next(g) 依然得到 4。
4 是向外产出的值,res 接收的是下一次恢复时传进来的值。
用 send() 向生成器传值
沿用前面定义的 foo(),创建一个新的生成器:
g = foo()
print(next(g)) # 先启动,停在第一个 yield
print(g.send(88)) # 将 88 传给暂停处的 yield 表达式
print(next(g)) # 再恢复一次,这次传入 None
g.close()
输出:
starting...
4
res: 88
4
res: None
4
g.send(88) 会同时完成两件事:让当前的 yield 表达式得到 88,并继续执行,直到下一个 yield 产出值。因此 print(g.send(88)) 打印的仍然是下一次产出的 4。
新生成器需要先通过 next(g) 或 g.send(None) 启动,不能直接向尚未启动的生成器 send(88)。普通 yield 只是暂停执行,不会自动启动线程,也不代表异步执行。
3. with:划定资源的使用范围
文件是理解 with 最直接的例子。下面的示例会在当前目录创建或覆盖 demo.txt,可以在练习目录中运行:
# 创建文件,写入内容
with open("demo.txt", "w", encoding="utf-8") as file:
file.write("你好,Python")
# 打开文件,读取内容
with open("demo.txt", "r", encoding="utf-8") as file:
content = file.read()
print("with内部的打印")
print(content)
print("里面,文件已关闭:", file.closed)
print("外面,文件已关闭:", file.closed)
输出:
with内部的打印
你好,Python
里面,文件已关闭: False
外面,文件已关闭: True
以读取部分为例:
| 代码 | 含义 |
|---|---|
open("demo.txt", "r", encoding="utf-8") |
以只读方式打开文件,按 UTF-8 解码文本 |
as file |
将上下文管理器进入方法的返回值绑定到 file |
content = file.read() |
读取全部文本,保存为字符串 |
| 离开缩进代码块 | 调用退出逻辑,由文件对象关闭文件 |
文件对象的进入方法返回它自身,所以这里的 file 就是打开的文件对象。
不用 with,相同的文件读取与清理可以写成:
file = open("demo.txt", "r", encoding="utf-8")
try:
content = file.read()
finally:
file.close()
with 将管理器的进入和退出操作组织到一起,让业务代码集中在文件读取本身。
文件关闭了,变量为什么还存在?
with 不会创建新的变量作用域。退出代码块后,file 变量仍然存在,只是它指向的文件对象已经关闭,不能继续调用 read() 读取。
content 保存的是已经读出的字符串,因此文件关闭后,仍然可以使用这个字符串。
“上下文”体现在哪里?
这里的上下文,是读取代码所处的资源状态:文件已经打开、使用只读模式、按照 UTF-8 解码,并且尚未关闭。
在其他场景中,这个条件可能是“当前已经持有锁”,也可能是“当前处于一个数据库事务中”。上下文管理器负责相应的进入和退出操作,例如释放锁,或者提交、回滚事务。
4. 用类实现上下文管理器
with 使用的是上下文管理协议。对于同步上下文管理器,关键是两个方法:__enter__() 和 __exit__()。
下面自己实现一个文件管理器,读取前面创建的 demo.txt:
class FileManager:
def __init__(self, path):
self.path = path
def __enter__(self):
print("1. 打开文件")
self.file = open(self.path, "r", encoding="utf-8")
# 返回什么,as 后面的变量就接收什么
return self.file
def __exit__(self, exc_type, exc_value, traceback):
print("3. 关闭文件")
self.file.close()
# 如果业务代码发生异常,清理后继续向外传播
return False
with FileManager("demo.txt") as file:
print("2. 读取文件:", file.read())
print("4. with 已结束")
输出:
1. 打开文件
2. 读取文件: 你好,Python
3. 关闭文件
4. with 已结束
执行顺序是:创建 FileManager 对象,调用它的 __enter__(),把返回的文件对象交给 file,执行缩进代码,最后调用 __exit__()。
as file 得到的是 __enter__() 的返回值,不一定是管理器对象自身。 在本例中,管理器是 FileManager 实例,交给业务代码使用的是其中的文件对象。
exit() 的参数和返回值
| 参数 | 正常退出时 | 因异常退出时 |
|---|---|---|
exc_type |
None |
异常类型,例如 ValueError |
exc_value |
None |
异常实例 |
traceback |
None |
异常的回溯对象 |
当代码块抛出异常时,__exit__() 返回 False 或 None,表示不抑制异常;返回真值,则表示抑制该异常。清理资源和处理错误是两件事,关闭文件不意味着业务错误自动消失。
退出处理的前提是 __enter__() 已经成功返回。如果打开文件时就失败,代码块不会执行,这个管理器的 __exit__() 也不会被调用。正常完成、通过 return 或 break 离开,以及因异常离开已进入的代码块,都会经过退出处理。
open() 是用 yield 封装的吗?
open() 返回的文件对象原生支持上下文管理协议,无须依赖 @contextmanager 或 yield。
文件对象的 __enter__() 返回自身,__exit__() 负责关闭文件。with 依赖的是这套协议,而不是要求对象内部必须使用 yield。
5. 用 @contextmanager 将类的写法简化成函数
如果只是封装一段“准备资源、交给调用者、最后清理”的逻辑,可以使用标准库装饰器 @contextmanager:
from contextlib import contextmanager
@contextmanager
def managed_file(path):
print("1. 打开文件")
file = open(path, "r", encoding="utf-8")
try:
yield file
finally:
print("3. 关闭文件")
file.close()
with managed_file("demo.txt") as file:
print("2. 读取文件:", file.read())
print("4. with 已结束")
输出:
1. 打开文件
2. 读取文件: 你好,Python
3. 关闭文件
4. with 已结束
调用装饰后的 managed_file() 得到上下文管理器;进入 with 时,管理器推进内部生成器,执行到 yield file,再将产出的文件对象交给 as file。
这时生成器暂停,业务代码开始运行。正常退出 with 时,管理器恢复生成器,让它执行清理并结束。如果业务代码抛出异常,管理器会把异常传回生成器的 yield 位置,所以这里使用 finally 确保清理。
可以将两种实现逐项对照:
| 任务 | 类实现 | @contextmanager 实现 |
|---|---|---|
| 准备资源 | 在 __enter__() 中执行 |
在 yield 前执行 |
提供给 as 的对象 |
__enter__() 的返回值 |
yield 产出的值 |
| 退出时清理 | 在 __exit__() 中执行 |
在包围 yield 的 finally 中执行 |
这里不需要手动调用 next() 或 send(),生成器的推进由上下文管理器负责。
6. 业务代码报错,清理还会执行吗?
沿用上面的 managed_file(),故意让业务代码抛出异常:
try:
with managed_file("demo.txt") as file:
print("2. 开始处理")
raise ValueError("模拟业务出错")
except ValueError as error:
print("捕获异常:", error)
print("文件已关闭:", file.closed)
输出:
1. 打开文件
2. 开始处理
3. 关闭文件
捕获异常: 模拟业务出错
文件已关闭: True
先执行清理,再由外层捕获业务异常。finally 负责关闭文件,没有把 ValueError 吞掉。
因此,只把 file.close() 写在 yield file 后面还不够:异常会在 yield 位置重新抛出,后面的普通语句可能被跳过。需要无论正常退出还是异常退出都执行的清理,应放进 finally。
如果在上下文管理器中使用 except 记录异常,却没有再次 raise,可能会将异常抑制。只想记录错误、仍需让调用方处理时,应记录后重新抛出。
7. 普通生成器和上下文管理器中的 yield 有什么不同?
yield 的暂停机制没有改变,变化的是使用方式:
| 比较项 | 普通生成器 | @contextmanager 包装的生成器 |
|---|---|---|
| 主要用途 | 按需产出数据 | 管理一次进入和退出 |
| 由谁推进 | next()、send() 或 for 等 |
上下文管理器内部机制 |
| 产出的值交给谁 | 迭代调用者 | with ... as 后面的变量 |
| 正常运行时 yield 的次数 | 可以零次、一次或多次 | 一次正常进入和完成的使用中,必须恰好一次 |
普通生成器可以像 foo() 一样不断产出 4。上下文管理器则需要在“交出资源”处暂停一次,然后在退出时结束。
如果被 @contextmanager 包装的生成器没有产出值就正常结束,会报 RuntimeError;正常退出时又产出第二个值,也会报 RuntimeError。资源获取阶段主动抛出的异常则正常传播,不属于“必须强行执行 yield”的情况。
另外,直接把一个普通生成器对象放到 with 后面不会自动获得上下文管理能力;这里需要 @contextmanager 的包装。每次使用 managed_file(path) 都会创建新的管理器,不要将同一个已经用过的生成器上下文管理器实例重复进入。
8. 实际开发中怎样选择?
- 对象已经支持
with,例如open()返回的文件对象:直接使用。 - 想封装简单的准备、使用、清理流程:优先考虑
@contextmanager。 - 本来就在编写管理资源的类,需要保存状态或提供其他方法:实现
__enter__()和__exit__()。 - 只是需要逐个产出数据:编写普通生成器,使用
yield和for,不需要为了使用yield而引入上下文管理器。
评论
填写昵称与邮箱即可评论,无需登录。