Python & AI Tutorials Logo
Python 编程

28. with 语句与上下文管理器

在第 27 章中,你已经使用过 with 语句来处理文件。它帮助你在读写数据时不必担心之后还要显式关闭文件。不过在那时,重点在于如何使用 with,而不是它真正意味着什么

在本章中,我们将退一步,从更宏观的角度来看。你将学习什么是上下文管理器(context manager),为什么手动管理资源可能存在风险,以及 with 语句如何在 Python 中提供一种安全且可靠的资源处理模式。你还会看到 with 并不局限于文件,并获得对它在幕后如何工作的概念性理解。

28.1) 从概念上理解上下文管理器

上下文管理器(context manager) 是一种对象,它定义了当你在代码中进入和退出某个特定上下文时应该发生什么。你可以把它想象成进入和离开一个房间:进入时你开灯;离开时你关灯——无论你在房间里期间发生了什么。

28.1.1) 资源管理问题

许多编程任务都涉及获取资源、使用资源,然后释放资源:

python
# 打开文件会获取一种资源(文件句柄)
file = open("data.txt", "r")
content = file.read()
# 使用该文件...
file.close()  # 释放资源

这种模式经常出现:

  • 打开并关闭文件
  • 在并发编程中获取并释放锁
  • 打开并关闭数据库连接
  • 分配并释放内存缓冲区

挑战在于确保资源始终会被释放,即使出现了问题。

28.1.2) 什么让一个对象成为上下文管理器

上下文管理器是任何实现了两个特殊方法的对象:

  1. __enter__():进入上下文时调用(在 with 代码块开始处)
  2. __exit__():退出上下文时调用(在 with 代码块结束处,即使发生错误也会调用)

你不需要自己实现这些方法就能使用上下文管理器——Python 的内置类型(如文件对象)已经具备它们。理解这个概念能帮助你识别自己何时正在使用上下文管理器。

python
# 文件对象是上下文管理器
# 它们有 __enter__ 和 __exit__ 方法
file = open("example.txt", "r")
print(hasattr(file, "__enter__"))  # Output: True
print(hasattr(file, "__exit__"))   # Output: True
file.close()

28.1.3) 基本模式:设置、使用、清理

上下文管理器遵循一个三阶段模式:

进入上下文

设置:调用 enter

使用资源

退出上下文

清理:调用 exit

资源已释放

设置阶段:获取资源(例如打开文件、连接数据库、获取锁)

使用阶段:使用资源完成工作(例如读取/写入文件、查询数据库、访问共享数据)

清理阶段:释放资源(例如关闭文件、断开数据库连接、释放锁)

关键洞察:清理阶段总会发生,无论使用阶段发生了什么。

28.2) 为什么手动资源管理有风险

在学习 with 语句之前,我们先理解为什么手动资源管理可能失败并导致问题。

28.2.1) 忘记关闭

最常见的错误就是忘记关闭资源:

python
# 读取配置文件
config_file = open("config.txt", "r")
settings = config_file.read()
# 糟糕!忘记关闭文件
# 文件句柄仍然保持打开状态

虽然 Python 最终会在程序结束时关闭文件,但让文件一直处于打开状态可能造成问题:

  • 资源耗尽:操作系统会限制可同时打开的文件数量
  • 文件锁定:其他程序可能无法访问该文件
  • 数据丢失:缓冲写入可能没有刷新到磁盘

28.2.2) 错误会阻止清理

即使你记得关闭资源,错误也可能阻止清理代码运行:

python
# 尝试处理文件
data_file = open("data.txt", "r")
content = data_file.read()
result = process_data(content)  # 如果这里抛出错误怎么办?
data_file.close()  # 如果 process_data() 失败,这一行永远不会执行!

如果 process_data() 抛出异常,程序会直接跳转到错误处理逻辑,从而跳过 close() 调用。文件将一直保持打开状态。

28.2.3) 多个退出点

带有多个 return 语句的函数会让清理变得更困难:

python
def read_first_valid_line(filename):
    file = open(filename, "r")
    
    for line in file:
        line = line.strip()
        if line and not line.startswith("#"):
            # 找到一行有效内容——但文件仍然是打开的!
            return line
    
    file.close()  # 只有在没有找到有效行时才会执行到这里
    return None

该函数在找到有效行时会提前返回,导致文件一直打开。你需要在每个 return 语句之前都加上 file.close()——这很容易忘记,也很难维护。

28.2.4) 复杂的错误处理

你可能会尝试使用 try-except-finally 来确保清理:

python
# 尝试正确处理错误
file = None
try:
    file = open("data.txt", "r")
    content = file.read()
    result = process_data(content)
except FileNotFoundError:
    print("File not found")
except ValueError:
    print("Invalid data format")
finally:
    if file is not None:
        file.close()

这种方式可行,但冗长且容易出错。你必须:

  • 在 try 代码块之前初始化变量
  • 在关闭之前检查资源是否成功获取
  • 记得包含 finally 代码块
  • 对每个资源重复这一模式

28.2.5) 真实世界的影响

这些问题并非只是理论上的。想象一个要处理成千上万个文件的程序:

python
# 警告:资源泄漏——仅用于演示
# 问题:文件从未被关闭
def process_many_files(filenames):
    results = []
    for filename in filenames:
        file = open(filename, "r")  # 打开一个文件
        data = file.read()
        results.append(analyze(data))
        # 错误:从不关闭文件
    return results
 
# 处理 1000 个文件后,你就有 1000 个打开的文件句柄!
# 最终,操作系统会拒绝打开更多文件

输出(在多次迭代之后):

OSError: [Errno 24] Too many open files: 'file_1001.txt'

程序崩溃是因为它耗尽了系统允许的文件句柄数量。这就是资源泄漏(resource leak)——资源被获取但从未被释放。

28.3) 在文件之外使用 with

with 语句适用于任何上下文管理器,而不仅仅是文件。让我们探索它如何解决我们已经识别的问题,并看看它在各种上下文中的用法。

28.3.1) with 语句的基本语法

with 语句结构很简单:

python
with expression as variable:
    # 使用资源的代码块
    # 缩进在 with 语句之下
# 资源在这里自动释放

expression 必须求值为一个上下文管理器对象。as variable 部分是可选的,但通常会写上——它为资源提供一个名字以便引用。

28.3.2) 使用 with 进行文件操作

下面是 with 语句如何改变文件处理方式的:

python
# 手动方式(有风险)
file = open("data.txt", "r")
content = file.read()
file.close()
 
# 使用 with 语句(安全)
with open("data.txt", "r") as file:
    content = file.read()
# 文件在这里自动关闭,即使发生错误也是如此

with 代码块结束时,文件保证会被关闭,无论代码是正常完成还是抛出了异常。

28.3.3) 多个上下文管理器

你可以在一条 with 语句中管理多个资源:

python
# 从一个文件读取并写入另一个文件
with open("input.txt", "r") as input_file, open("output.txt", "w") as output_file:
    for line in input_file:
        processed = line.upper()
        output_file.write(processed)
# 两个文件都在这里自动关闭

这等价于嵌套 with 语句,但更简洁:

python
# 嵌套 with 语句(等价但更冗长)
with open("input.txt", "r") as input_file:
    with open("output.txt", "w") as output_file:
        for line in input_file:
            processed = line.upper()
            output_file.write(processed)

两种方式都能保证两个文件都被正确关闭,即使在处理过程中发生错误也是如此。

28.3.4) 处理压缩文件

Python 的 gzip 模块提供了用于读写压缩文件的上下文管理器:

python
import gzip
 
# 写入压缩数据
with gzip.open("data.txt.gz", "wt") as compressed_file:
    compressed_file.write("This text will be compressed\n")
    compressed_file.write("Saving space on disk\n")
# 文件自动关闭并完成压缩
 
# 读取压缩数据
with gzip.open("data.txt.gz", "rt") as compressed_file:
    content = compressed_file.read()
    print(content)

输出:

This text will be compressed
Saving space on disk

with 语句确保压缩文件能被正确“收尾”,这对压缩来说至关重要——不完整的压缩可能导致文件损坏。

28.3.5) 临时切换目录

当你需要临时切换当前工作目录时,手动管理可能有风险:

python
import os
 
# 当前目录
print(f"Starting in: {os.getcwd()}")
 
# 手动切换目录(有风险)
original_dir = os.getcwd()
os.chdir("/tmp")
print(f"Now in: {os.getcwd()}")
process_files()  # 如果这里发生错误,我们可能无法回到 original_dir
os.chdir(original_dir)

如果 process_files() 抛出异常,程序将无法回到原始目录,这可能会导致后续代码出现意外行为。

Python 3.11 引入了 contextlib.chdir(),它是一个上下文管理器,可保证返回到原始目录:

python
import os
from contextlib import chdir
 
print(f"Starting in: {os.getcwd()}")
 
# 使用上下文管理器(安全)
with chdir("/tmp"):
    print(f"Temporarily in: {os.getcwd()}")
    process_files()  # 即使这里抛出错误,我们也会回到原始目录
    
print(f"Back in: {os.getcwd()}")
# 自动回到原始目录

with 代码块结束时,目录切换会被自动回滚,无论代码是正常完成还是抛出了异常。

28.3.6) 并发编程中的线程锁

在并发编程中(会在高级主题中介绍),锁也是上下文管理器:

python
# 概念示例(我们会在高级主题中学习 threading)
import threading
 
lock = threading.Lock()
 
# 手动管理锁(有风险)
lock.acquire()
# 临界区——如果发生错误怎么办?
lock.release()  # 可能不会执行
 
# 使用 with 语句(安全)
with lock:
    # 临界区
    # 锁会自动释放,即使发生错误也是如此
    pass

28.4) with 语句在幕后如何工作(仅概念)

理解 with 语句在内部如何工作,可以帮助你更好地体会它的强大之处,并识别何时在使用上下文管理器。本节提供概念性概览——你不需要自己实现这些细节。

28.4.1) 两个特殊方法

每个上下文管理器都实现了两个特殊方法,Python 会自动调用它们:

__enter__(self):当 with 代码块开始时调用

  • 执行设置操作(打开文件、获取锁等)
  • 返回会被赋值给 as 后变量的资源对象
  • 如果没有 as 子句,返回值会被忽略

__exit__(self, exc_type, exc_value, traceback):当 with 代码块结束时调用

  • 执行清理操作(关闭文件、释放锁等)
  • 接收发生的异常相关信息
  • 总会被调用,即使抛出了异常也是如此
  • 可以通过返回 True 来抑制异常(很少这样做)

28.4.2) Python 如何执行一条 with 语句

让我们跟踪 Python 执行一条 with 语句时发生了什么:

python
with open("data.txt", "r") as file:
    content = file.read()
    print(content)

下面是逐步执行过程:

文件对象Python 解释器你的代码文件对象Python 解释器你的代码执行 with 语句调用 __enter__()返回文件对象赋值给 'file' 变量调用 file.read()返回内容打印内容退出 with 代码块调用 __exit__()关闭文件返回 None继续执行

步骤 1:Python 对 open("data.txt", "r") 求值,创建一个文件对象

步骤 2:Python 调用该文件对象的 __enter__() 方法

步骤 3__enter__() 返回文件对象本身,并将其赋值给 file

步骤 4:Python 执行缩进的代码块

步骤 5:当代码块结束时(正常结束或因异常结束),Python 调用 __exit__()

步骤 6__exit__() 关闭文件并执行清理

步骤 7:如果发生了异常,Python 会在清理之后重新抛出异常

28.4.3) 上下文管理器中的异常处理

with 代码块内部发生异常时,Python 会把异常信息传给 __exit__()

python
# 发生错误时会怎样
try:
    with open("data.txt", "r") as file:
        content = file.read()
        result = int(content)  # 可能抛出 ValueError
        print(result)
except ValueError as e:
    print(f"Invalid data: {e}")
# 在 except 代码块运行之前,文件已经关闭

当发生 ValueError 时的执行流程:

进入 with 代码块

调用 enter

执行:content = file.read

执行:result = int content

抛出 ValueError

携带异常信息调用 exit

关闭文件

重新抛出 ValueError

except 代码块捕获它

关键点:异常传播之前会先调用 __exit__(),从而确保即使发生错误也会进行清理。

28.4.4) 一个简单的心智模型

with 语句看作一种保证:

python
with resource_manager as resource:
    # 使用资源
    pass
# Python 保证清理已经发生

无论代码块中发生什么——正常完成、return 语句、异常,甚至系统错误——Python 都会调用 __exit__() 来进行清理。正是这种保证让 with 如此强大,也解释了为什么你在处理资源时应该尽量使用它。


本章关键要点:

  • 上下文管理器(context manager) 为资源定义设置与清理操作
  • 手动资源管理有风险,因为可能忘记清理、遇到错误或存在多个退出点
  • with 语句保证清理一定会发生,即使出现错误也是如此
  • 对文件使用 with,以及对任何需要清理的资源都应使用它
  • 多个资源可以在一条 with 语句中进行管理
  • 在幕后with 会自动调用 __enter__()__exit__() 方法
  • __exit__() 总会运行,确保资源被正确释放

with 语句把容易出错的手动资源管理转换成自动、可靠的清理机制。无论你是在处理文件、数据库连接、锁,还是任何其他需要正确清理的资源,都应该使用它。你的代码会更安全、更整洁,也更专业。

© 2025. Primesoft Co., Ltd.
support@primesoft.ai