Menu

Python 上下文管理器:with 语句详解

with 语句到底在做什么:为文件、锁、数据库连接以及所有需要可靠关闭的资源自动做清理。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

会自己收拾残局的语句

程序中打开的每一个资源,比如文件、网络连接、数据库句柄、锁,在用完后都需要关闭。忘了关,就会泄漏内存、占着锁挡住其他进程,或者在崩溃时损坏文件。Python 的 with 语句会替你处理这些。

大家最先接触的写法是读取文件:

with open("notes.txt") as f:
    contents = f.read()
    print(contents)

有两件事会自动发生。进入时,open() 给你一个绑定到 f 的文件对象。退出时,无论代码块是正常结束、提前返回还是抛出异常,Python 都会替你调用 f.close()。

就这样。这就是 with 的全部意义。

with 代替了什么

在有上下文管理器之前,同样安全的写法是 try/finally:

f = open("notes.txt")
try:
    contents = f.read()
    print(contents)
finally:
    f.close()

只是为了“读一个文件,用完关掉”,就要写五行固定格式的代码。在一个更大的程序里,每个 open 都这样写,你就能体会到 with 的好处了。with 更简短,更不容易写错,而且不可能忘记清理。

打开多个资源

你可以在一个 with 中绑定多个上下文管理器:

with open("input.txt") as src, open("output.txt", "w") as dst:
    dst.write(src.read().upper())

两个文件在进入时打开,在退出时关闭。如果第一个 open 成功而第二个抛出异常,Python 仍然会关闭第一个文件:这套机制能正确处理只完成了一部分的初始化。

资源比较多时,用括号的写法(Python 3.10+)更清晰:

with (
    open("a.txt") as a,
    open("b.txt") as b,
    open("c.txt") as c,
):
    ...

上下文管理器到底是什么

任何定义了 __enter__ 和 __exit__ 的对象都是上下文管理器。这个协议非常简单:

  • __enter__(self) 在 with 代码块开始时运行。它的返回值就是 as name 绑定的对象。
  • __exit__(self, exc_type, exc_value, traceback) 在代码块结束时运行,无论是怎么结束的。如果是异常导致退出,异常信息会被传进来,上下文管理器可以检查它,也可以把它压下去。

下面是一个最简单的例子,它为所包裹的代码块计时:

with Timer(): 创建这个对象,调用它的 __enter__,运行代码块主体,再调用 __exit__。没有文件,也没有锁,只是给“做一件事,测一下花了多长时间”加了一层小小的包装。

contextlib.contextmanager 简便写法

为每个上下文管理器都定义一个类,实在有点重。contextlib.contextmanager 能把一个生成器函数变成上下文管理器:用一个 yield 把“之前”和“之后”分开:

yield 之前的所有代码相当于 __enter__ 的行为,之后的所有代码相当于 __exit__。try/finally 保证即使代码块主体抛出异常,清理代码也会运行。

你要写的大多数自定义上下文管理器都符合这种形式。优先使用这种装饰器写法;只有当你需要生成器形式表达不了的东西时,才改用类。

临时修改某样东西

一种常见的模式:设置某样东西,使用它,然后恢复原样。上下文管理器能把它表达得很清楚:

任何“先设置,再恢复”的模式,比如环境变量、日志详细程度、功能开关、测试夹具,都很自然地适合写成上下文管理器。调用者不需要记得去恢复任何东西。

压制异常

__exit__ 方法可以返回 True,告诉 Python“这个异常我处理了,把它吞掉吧”。这种情况很少见,通常也是代码有问题的信号,但 contextlib.suppress 就是这样工作的:

suppress(FileNotFoundError) 让 FileNotFoundError 什么也不发生。把它用在真正可有可无的操作上,也就是“试一下,不成功也无所谓”的情况。不要用它来掩盖你还没考虑清楚的异常。

你会遇到的其他上下文管理器

只要你开始留意,就会发现上下文管理器在标准库中随处可见:

import threading
from pathlib import Path

# Locks - guarantee release even if the critical section raises.
lock = threading.Lock()
with lock:
    ...

# tempfile - delete the temp file when you're done.
from tempfile import TemporaryDirectory
with TemporaryDirectory() as tmp:
    path = Path(tmp) / "scratch.txt"
    path.write_text("hello")

# Database connections - close the connection (or end the transaction).
import sqlite3
with sqlite3.connect(":memory:") as conn:
    conn.execute("CREATE TABLE t (x INTEGER)")

第三方库也遵循同样的约定。当你看到 with something as x: 时,它几乎总是意味着“在这个代码块执行期间使用 x,结束后清理”。

什么时候不该用 with

  • 你其实没有初始化和清理的步骤时。 无缘无故地把任意代码包进上下文管理器,只会增加干扰。
  • 你需要在许多互不相关的代码块中使用同一个资源时。 在一个很长的脚本里,让一个 with 从头开到尾,会让人看不清清理的范围到底是什么。可以考虑改用一个持有该资源的类。
  • 装饰器更合适时。 有些重复的模式(重试、日志、计时)写成函数上的 @decorator,比写成函数内部的 with ...: 读起来更自然。在调用处哪种写法读起来更好,就选哪种。

大多数时候,用 with 都是对的。少数例外情况,只要你留心,很容易就能发现。

下一步:操作真实的文件

现在你已经知道 with open(...) as f: 背后的机制了,而你九成的时候都会在这个场景中用它。下一章就会用它来读取、写入和浏览磁盘上的文件。

常见问题

Python 中的 with open 有什么作用?

with open(path) as f: 会打开文件,并在整个代码块执行期间把它绑定到 f。当代码块结束时,无论是正常结束还是因为异常结束,Python 都会自动关闭文件。你不需要写 f.close();with 语句保证了这一点。

为什么要用 with,而不是直接用 open()?

因为即使代码块执行到一半抛出了异常,with 也会关闭文件。直接用 open() 的话,你得在每一条代码路径上,包括出错的路径,都记得调用 close()。with 更安全,也更简短。

怎么用一条 with 语句打开多个文件?

用逗号分隔多个上下文管理器:with open('a.txt') as a, open('b.txt') as b:。进入时两个文件都会打开,退出时按相反的顺序关闭。需要同时使用多个资源时,这种写法可以代替嵌套的 with 语句。

Coddy 编程语言插图

和 Coddy 一起学编程

开始