写在最前
打开文件后要关闭,加锁后要解锁,临时调整设置后要恢复——这些操作都有一个共同点:事情做完以后,还有收尾工作。with 就是 Python 用来组织这类代码的语句。
本文从一个学习记录文件入手,逐步说明 with 的用法和执行过程,再讨论异常、多个资源以及自定义上下文管理器。示例均重新编写,使用 Python 标准库;建议使用 Python 3.10 或更新版本。读到自定义类和装饰器时,如果还不熟悉相关语法,可以先掌握前面的文件操作部分。
文件示例会在当前工作目录创建演示文件,建议在单独的练习文件夹运行。"w" 模式会覆盖同名文件的原有内容。
先看一个实际问题:谁来关闭文件?
假设要保存 ZHOU 今天的学习记录,最直接的做法是先打开文件,再写入内容,最后关闭文件。但是,如果写入时发生异常,程序可能无法走到最后那句 close()。
用 try...finally 可以把关闭操作安排到清理阶段:
note = open("zhou-review.txt", "w", encoding="utf-8")
try:
note.write("ZHOU:今天复习了循环和条件判断。\n")
finally:
note.close()
print(note.closed)
输出:
True
这里的 finally 表示:离开这段受保护的代码时,要执行关闭操作。open() 放在 try 前面,是因为如果打开文件本身就失败,就没有成功取得的文件对象可以关闭。
文件对象已经提供了配套的上下文管理行为,因此可以把同一件事写成:
with open("zhou-review.txt", "w", encoding="utf-8") as note:
note.write("ZHOU:今天复习了循环和条件判断。\n")
print(note.closed)
输出仍然是 True。使用文件对象的 with 时,离开代码块会关闭文件;即使代码块因异常而退出,也会执行相应的关闭操作。这个行为由文件对象的上下文管理器提供。Python 官方文件操作教程
基本语法:with 和 as 分别负责什么?
一般形式如下,其中 as target 可以省略:
with expression as target:
需要在上下文中执行的代码
expression 先求值,得到一个上下文管理器(Context Manager);with 负责按协议进入和退出这个上下文。as target 则把进入上下文时返回的值绑定到名字 target 上。
需要特别区分:as 绑定的是 __enter__() 的返回值,不一定是 expression 得到的那个对象。 文件对象的 __enter__() 返回文件对象自身,因此初学时很容易忽略这个差别。
再看一个完整的读写过程:
with open("zhou-plan.txt", "w", encoding="utf-8") as writer:
writer.write("复习 with\n完成两道练习\n")
with open("zhou-plan.txt", "r", encoding="utf-8") as reader:
plan = reader.read()
print("代码块内,文件已关闭:", reader.closed)
print("代码块外,文件已关闭:", reader.closed)
print(plan, end="")
输出:
代码块内,文件已关闭: False
代码块外,文件已关闭: True
复习 with
完成两道练习
plan 保存的是已经读出的字符串,因此关闭文件后仍然可以使用。reader 这个名字也还存在,只是它引用的文件对象已经关闭,不能继续执行 read() 等读写操作。
with 不会创建新的变量作用域(Scope)。 不要把“离开上下文”理解成“删除块内所有变量”。
执行过程:进入、运行、退出
普通的同步上下文管理协议(Context Management Protocol)由两个方法组成:
| 方法 | 调用时机 | 作用 |
|---|---|---|
__enter__(self) | 进入上下文时 | 准备资源或状态,返回提供给 as 的值 |
__exit__(self, exc_type, exc_value, traceback) | 离开已成功进入的上下文时 | 执行收尾,并决定是否抑制传入的异常 |
下面的流程图区分了正常退出和异常退出:

图中省略了特殊方法查找等实现细节;“进入成功”指 __enter__() 正常返回。图中的异常分支也包括给 as 目标赋值时发生的错误。
如果 __enter__() 自己抛出异常,Python 不会再替这个管理器调用 __exit__()。因此,自定义管理器在进入过程中取得了一部分资源、随后又失败时,需要自行清理已经取得的部分。相反,只要进入成功,正常执行完毕,或因为 return、break、continue 离开当前 with 块,都会经过退出处理。Python 官方 with 语句说明
这里说的是 Python 正常的控制流程。进程被强制终止等情况不属于这套清理保证;清理方法本身也可能失败。
用一个小类观察调用顺序
下面的 StudySession 模拟一次学习会话,进入时返回一份信息字典,退出时打印结束信息。它没有文件资源,目的是把调用顺序显示出来。
class StudySession:
def __init__(self, learner):
self.learner = learner
def __enter__(self):
print(f"开始学习:{self.learner}")
return {"learner": self.learner, "topic": "with"}
def __exit__(self, exc_type, exc_value, traceback):
print("结束学习:整理记录")
return False
session = StudySession("ZHOU")
with session as info:
print(f"当前主题:{info['topic']}")
print("as 绑定的对象是否就是管理器:", info is session)
print("继续下一项任务")
输出:
开始学习:ZHOU
当前主题:with
as 绑定的对象是否就是管理器: False
结束学习:整理记录
继续下一项任务
这里有两个对象:session 是管理器,info 是 __enter__() 返回的字典。with 在进入和退出时调用的是管理器的方法,不要求 info 也具有这些方法。
发生异常时:清理和处理错误是两件事
with 会安排退出操作,但是否把异常继续交给外层代码,要看管理器如何实现。以支持上下文管理的内存文本流 StringIO 为例:
from io import StringIO
buffer = StringIO()
try:
with buffer as report:
report.write("ZHOU 的成绩记录\n")
raise ValueError("成绩必须在 0 到 100 之间")
except ValueError as error:
print("捕获异常:", error)
print("文本流已关闭:", buffer.closed)
输出:
捕获异常: 成绩必须在 0 到 100 之间
文本流已关闭: True
可以同时观察到两件事:文本流关闭了,异常仍然传播到了外面的 except。换句话说,资源收尾并不等于错误消失。
exit() 的三个参数与返回值
当没有异常传入时,exc_type、exc_value 和 traceback 都是 None。当异常导致退出时,它们分别描述异常类型、异常对象和追踪信息(Traceback)。
| 退出方法的结果 | 有异常传入时的行为 |
|---|---|
返回假值,如 False、None | 不抑制原异常,交给外层处理 |
返回真值,如 True | 抑制原异常,继续执行整个 with 之后的代码 |
| 退出方法自己抛出异常 | 新异常向外传播,原异常通常成为其异常上下文 |
“抑制异常”(Exception Suppression)不会让程序回到出错语句的下一行。出错之后,当前代码块的剩余部分已经被跳过。如果没有异常传入,退出方法的普通返回值不会改变原本的控制流。Python 官方上下文管理器协议
因此,__exit__() 不写 return 时默认返回 None,本身并不是错误;是否抑制异常应该由管理器的用途决定。
多个资源:先进入的,后退出
整理学习清单时,可能需要一边读取原文件,一边写入新的编号文件:
with open("zhou-topics.txt", "w", encoding="utf-8") as seed:
seed.write("条件判断\n循环语句\n上下文管理\n")
with (
open("zhou-topics.txt", "r", encoding="utf-8") as source,
open("zhou-topics-numbered.txt", "w", encoding="utf-8") as target,
):
for number, topic in enumerate(source, start=1):
target.write(f"{number}. {topic.strip()}\n")
print(source.closed, target.closed)
with open("zhou-topics-numbered.txt", "r", encoding="utf-8") as result:
print(result.read(), end="")
输出:
True True
1. 条件判断
2. 循环语句
3. 上下文管理
这种写法相当于嵌套的 with:先进入 source,再进入 target;退出时先处理 target,再处理 source。如果打开第二个文件失败,已经成功进入的第一个文件仍会得到退出处理。
多项上下文管理器使用括号换行的写法需要 Python 3.10 或更新版本。Python 官方多项 with 语法
用 contextlib.contextmanager 编写自己的管理器
如果进入和退出逻辑很短,可以使用 contextlib.contextmanager。它是一个装饰器(Decorator),可以把符合约定的生成器函数(Generator Function)变成上下文管理器工厂。
下面临时调整一份配置中的学习模式,退出时恢复旧值:
from contextlib import contextmanager
@contextmanager
def temporary_mode(settings, mode):
previous = settings["mode"]
settings["mode"] = mode
try:
yield settings
finally:
settings["mode"] = previous
settings = {"mode": "review"}
print("进入前:", settings["mode"])
try:
with temporary_mode(settings, "practice") as current:
print("上下文内:", current["mode"])
raise RuntimeError("练习被中断")
except RuntimeError as error:
print("捕获异常:", error)
print("退出后:", settings["mode"])
输出:
进入前: review
上下文内: practice
捕获异常: 练习被中断
退出后: review
这份实现约定配置中原本就有 mode 键。它展示的是单次调用中的临时恢复,不是共享配置的并发控制方案。
理解这段代码,可以把 yield 当作分界点:它前面的代码准备上下文,产出的值交给 as,退出时生成器从这个位置恢复。若 with 代码块抛出异常,异常会在生成器的 yield 位置重新抛入,所以清理逻辑应该放在 finally 中,而不是只顺序写在 yield 后面。
正常进入的这类管理器必须恰好产出一次值。若在生成器内捕获异常仅用于记录日志,应重新 raise,否则可能意外抑制调用方的错误。Python 官方 contextmanager 文档
with 还能管理哪些事情?
这里有一些例子:
线程锁:把加锁和解锁放在一起
锁(Lock)用于协调多个线程对共享数据的访问。支持上下文管理的锁在进入时获取锁,退出时释放锁:
from threading import Lock, Thread
guard = Lock()
completed = 0
def record_one():
global completed
with guard:
completed += 1
workers = [Thread(target=record_one) for _ in range(6)]
for worker in workers:
worker.start()
for worker in workers:
worker.join()
print("已完成练习:", completed)
输出:
已完成练习: 6
这里保护的是 completed += 1 所在的临界区(Critical Section)。参与访问这份共享状态的线程需要遵守同一套锁规则,不能因为某处出现了 with guard,就认为整个程序自动具有线程安全性。Python 官方锁的 with 用法
临时数值上下文:计算结束后恢复设置
decimal.localcontext() 可以临时改变十进制计算的上下文,而不永久改变外层设置:
from decimal import Decimal, getcontext, localcontext
original_precision = getcontext().prec
with localcontext() as context:
context.prec = 5
result = Decimal("2") / Decimal("7")
print("本次计算:", result)
print("精度已恢复:", getcontext().prec == original_precision)
print("计算结果仍可用:", result)
输出:
本次计算: 0.28571
精度已恢复: True
计算结果仍可用: 0.28571
这里的精度指有效数字数量,不是固定的小数位数。退出上下文会恢复计算设置,但不会把已经得到的 result 重新计算一遍。Python 官方 localcontext 文档
SQLite:事务结束不等于连接关闭
事务(Transaction)决定一组数据库修改是提交(Commit)还是回滚(Rollback)。关闭数据库连接则是另一个操作。
Python 的 sqlite3.Connection 可以用于 with,但它的退出行为主要处理事务,不会自动关闭连接。因此,需要同时管理事务和连接寿命时,可以用外层的 closing() 负责关闭连接,内层的 with connection 负责事务。
下面使用内存数据库,模拟一次因为重复编号而失败的批量写入:
import sqlite3
from contextlib import closing
with closing(sqlite3.connect(":memory:", isolation_level="DEFERRED")) as connection:
connection.execute(
"CREATE TABLE practice (id INTEGER PRIMARY KEY, topic TEXT NOT NULL)"
)
try:
with connection:
connection.execute(
"INSERT INTO practice VALUES (?, ?)",
(1, "with 关键字"),
)
connection.execute(
"INSERT INTO practice VALUES (?, ?)",
(1, "上下文管理器"),
)
except sqlite3.IntegrityError:
print("编号重复,本次写入已回滚")
count = connection.execute("SELECT COUNT(*) FROM practice").fetchone()[0]
print("当前记录数:", count)
print("已退出外层 closing,连接已关闭")
输出:
编号重复,本次写入已回滚
当前记录数: 0
已退出外层 closing,连接已关闭
本例在默认的传统事务控制方式下设置 isolation_level="DEFERRED",第一条插入开启事务,第二条插入失败后触发回滚。如果改用其他事务设置,尤其是 autocommit=True,不能照搬这段事务行为说明。数据库连接退出上下文是否提交、回滚或关闭,也不能从一种驱动推广到所有驱动。Python 官方 SQLite 连接上下文说明
写 with 时,检查这几个细节
| 要检查的问题 | 对应做法 |
|---|---|
| 对象是否支持上下文管理? | 查它是否提供相应协议;只有 close() 并不自动意味着能直接用于 with |
as 后拿到的是什么? | 看 __enter__() 的返回值,可能是管理器,也可能是另一个对象 |
| 退出时具体做什么? | 区分关闭文件、释放锁、恢复设置和结束事务 |
| 异常是否被抑制? | 看退出方法的返回值及文档,不要仅根据 with 猜测 |
| 清理代码是否覆盖异常路径? | 自定义生成器式管理器时,把收尾放进 finally |
| 资源是否持有太久? | 让上下文覆盖实际需要资源的代码,避免无关工作占用文件或锁 |
对于只提供 close()、没有上下文管理协议的对象,contextlib.closing() 可以在退出时调用它的关闭方法,但不会自动增加对象本来没有的事务或异常恢复能力。Python 官方 closing 文档
学习 with 时,可以先把日常文件读写改写一遍,再尝试给自定义管理器打印进入和退出日志。观察一次正常结束、一次代码块抛出异常,就能把“何时清理”和“异常交给谁”这两件事分开理解。