Python 上下文管理器:`with` 语句的原理与实战

2026-05-21 23 预计阅读时间:1 分钟
来源:realpython.com AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:8 分钟

每次写 with open('data.txt') as f: 时,你已经在用上下文管理器了。但很多人对它的理解停留在"自动关闭文件"这一层,遇到需要管理锁、数据库连接、临时目录切换的场景,往往还是手动写 try/finally。这篇文章把 with 的机制拆开,再给出几种自己写上下文管理器的实用方式。

with 到底做了什么

with 语句的本质是:在进入代码块时获取资源,在退出代码块时释放资源——无论代码块是正常结束还是抛了异常。展开来看,它等价于:

mgr = open('data.txt')
f = mgr.__enter__()
try:
    # 你的代码块
    content = f.read()
finally:
    mgr.__exit__(None, None, None)

__enter__ 返回的对象赋给 as 后面的变量;__exit__ 在 finally 里被调用,保证资源释放。三个参数 exc_type, exc_val, exc_tb 在没有异常时全是 None,有异常时传入异常信息。如果 __exit__ 返回 True,异常会被吞掉;返回 False(默认),异常继续向外传播。

常见的内置上下文管理器

除了 open(),标准库里有不少现成的:

管理器 用途
threading.Lock 获取/释放互斥锁
contextlib.suppress(Exception) 静默忽略指定异常
contextlib.redirect_stdout(target) 临时重定向标准输出
decimal.Context 临时切换 decimal 精度
tempfile.TemporaryDirectory 创建并自动清理临时目录

一个容易被忽略的用法——用 suppress 替代空的 try/except:

import contextlib

# 以前你可能这样写
try:
    os.remove('tmp.log')
except FileNotFoundError:
    pass

# 现在可以这样写
with contextlib.suppress(FileNotFoundError):
    os.remove('tmp.log')

意图更清晰,也少了一层缩进。

手写类式上下文管理器

当你需要管理自定义资源(数据库连接、REST 会话、临时修改全局状态),最直接的方式是写一个带 __enter____exit__ 的类。

下面是一个"临时切换工作目录"的完整示例,可以直接复制运行:

import os

class WorkingDirectory:
    """进入时切换到指定目录,退出时恢复原目录,即使内部抛异常也不丢。"""

    def __init__(self, target_dir):
        self.target_dir = target_dir
        self.original_dir = None

    def __enter__(self):
        self.original_dir = os.getcwd()
        os.chdir(self.target_dir)
        return self  # as 后面的变量拿到 self,可以继续访问属性

    def __exit__(self, exc_type, exc_val, exc_tb):
        os.chdir(self.original_dir)
        return False  # 不吞异常,让它正常传播


# 使用示例
print("当前目录:", os.getcwd())

with WorkingDirectory('/tmp'):
    print("进入后目录:", os.getcwd())
    # 即使这里 raise ValueError("boom"),退出后目录也会恢复

print("退出后目录:", os.getcwd())

运行前把 /tmp 改成你系统上存在的路径。关键点:__exit__ 里不返回 True,所以如果代码块抛异常,异常会正常上报,但目录已经恢复了——这正是 try/finally 手写时容易遗漏的地方。

contextlib 简化:生成器方式

如果不需要复杂的状态管理,contextlib.contextmanager 装饰器比类写法省很多代码。你只需要写一个生成器,yield 之前是"进入",yield 之后是"退出"。

一个实际场景:给长时间运行的函数加计时日志。

import contextlib
import time
import logging

logging.basicConfig(level=logging.INFO)

@contextlib.contextmanager
def timer(label):
    start = time.perf_counter()
    yield  # 这里暂停,交给 with 代码块执行
    elapsed = time.perf_counter() - start
    logging.info(f"{label} 耗时 {elapsed:.3f}s")


# 使用
with timer("数据加载"):
    # 模拟耗时操作
    _ = [i ** 2 for i in range(10 ** 6)]

# 输出类似: INFO:root:数据加载 耗时 0.042s

生成器方式的注意事项:yield 后面的清理代码相当于 __exit__ 的 finally 部分,一定会执行。但如果想在 yield 之后捕获代码块的异常,需要自己写 try/except 包住 yield——不捕获的话异常会正常传播,清理代码依然先跑完。

多资源同时管理

with 支持同时管理多个资源:

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

但有个细节:如果第二个 open() 抛异常,第一个文件已经进入__enter__,所以它的 __exit__ 仍会被调用。Python 3.1+ 的多 with 保证了这一点。如果用嵌套写法(with ... : with ... :),行为一样,只是缩进更深。

对于三个以上的资源,嵌套写法可读性下降,可以用 contextlib.ExitStack

import contextlib

files = ['a.txt', 'b.txt', 'c.txt']
with contextlib.ExitStack() as stack:
    handles = [stack.enter_context(open(f)) for f in files]
    # 所有文件在退出时自动关闭,即使中间某个 open 失败

ExitStack 会按进入的逆序调用各管理器的 __exit__,和嵌套 with 的语义一致。

什么时候该自己写上下文管理器

一个简单的判断标准:如果你写了 try/finally 只是为了在 finally 里做清理,就该考虑上下文管理器。典型场景:

  • 数据库连接/事务的获取与回滚
  • 文件锁或线程锁的获取与释放
  • 临时修改环境变量、工作目录、日志级别后恢复
  • 测试中 mock 某个外部服务后还原

不需要上下文管理器的情况:清理逻辑本身就很简单且只出现一次,包一层管理器反而增加阅读成本。工具的选择取决于复用频率——同一个 try/finally 模式出现两次以上,就值得提取。

检查清单

写自己的上下文管理器时,对照这几条:

  1. __exit__ 是否处理了异常参数——决定吞掉还是传播,别默认返回 True
  2. __enter__ 返回什么——返回 self 最常见,但也可以返回其他对象(比如 open() 返回文件对象而非管理器本身)。
  3. 生成器方式中 yield 是否被 try 包住——如果需要在退出时根据异常做不同处理,必须包;如果只做无条件清理,不用包。
  4. 多资源场景是否用了 ExitStack——超过两个资源时优先考虑。
  5. 清理顺序是否正确——ExitStack 和嵌套 with 都是逆序释放,和直觉一致,但自己手写类组合时要注意。

上下文管理器不是什么高级魔法,它是 Python 把"获取-使用-释放"这个模式标准化后的产物。用好内置的、在重复场景自己写一个,代码里裸 try/finally 的数量会明显减少。


相关推荐