Furthe77oad
Furthe77oad
发布于 2026-09-16 / 0 阅读
0
0

Python with 关键字

写在最前

打开文件后要关闭,加锁后要解锁,临时调整设置后要恢复——这些操作都有一个共同点:事情做完以后,还有收尾工作。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)离开已成功进入的上下文时执行收尾,并决定是否抑制传入的异常

下面的流程图区分了正常退出和异常退出:

Python with 的执行流程:进入上下文、执行代码块、正常清理或异常处理

图中省略了特殊方法查找等实现细节;“进入成功”指 __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 时,可以先把日常文件读写改写一遍,再尝试给自定义管理器打印进入和退出日志。观察一次正常结束、一次代码块抛出异常,就能把“何时清理”和“异常交给谁”这两件事分开理解。

参考资料


评论