28. with 语句与上下文管理器
在第 27 章中,你已经使用过 with 语句来处理文件。它帮助你在读写数据时不必担心之后还要显式关闭文件。不过在那时,重点在于如何使用 with,而不是它真正意味着什么。
在本章中,我们将退一步,从更宏观的角度来看。你将学习什么是上下文管理器(context manager),为什么手动管理资源可能存在风险,以及 with 语句如何在 Python 中提供一种安全且可靠的资源处理模式。你还会看到 with 并不局限于文件,并获得对它在幕后如何工作的概念性理解。
28.1) 从概念上理解上下文管理器
上下文管理器(context manager) 是一种对象,它定义了当你在代码中进入和退出某个特定上下文时应该发生什么。你可以把它想象成进入和离开一个房间:进入时你开灯;离开时你关灯——无论你在房间里期间发生了什么。
28.1.1) 资源管理问题
许多编程任务都涉及获取资源、使用资源,然后释放资源:
# 打开文件会获取一种资源(文件句柄)
file = open("data.txt", "r")
content = file.read()
# 使用该文件...
file.close() # 释放资源这种模式经常出现:
- 打开并关闭文件
- 在并发编程中获取并释放锁
- 打开并关闭数据库连接
- 分配并释放内存缓冲区
挑战在于确保资源始终会被释放,即使出现了问题。
28.1.2) 什么让一个对象成为上下文管理器
上下文管理器是任何实现了两个特殊方法的对象:
__enter__():进入上下文时调用(在with代码块开始处)__exit__():退出上下文时调用(在with代码块结束处,即使发生错误也会调用)
你不需要自己实现这些方法就能使用上下文管理器——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) 基本模式:设置、使用、清理
上下文管理器遵循一个三阶段模式:
设置阶段:获取资源(例如打开文件、连接数据库、获取锁)
使用阶段:使用资源完成工作(例如读取/写入文件、查询数据库、访问共享数据)
清理阶段:释放资源(例如关闭文件、断开数据库连接、释放锁)
关键洞察:清理阶段总会发生,无论使用阶段发生了什么。
28.2) 为什么手动资源管理有风险
在学习 with 语句之前,我们先理解为什么手动资源管理可能失败并导致问题。
28.2.1) 忘记关闭
最常见的错误就是忘记关闭资源:
# 读取配置文件
config_file = open("config.txt", "r")
settings = config_file.read()
# 糟糕!忘记关闭文件
# 文件句柄仍然保持打开状态虽然 Python 最终会在程序结束时关闭文件,但让文件一直处于打开状态可能造成问题:
- 资源耗尽:操作系统会限制可同时打开的文件数量
- 文件锁定:其他程序可能无法访问该文件
- 数据丢失:缓冲写入可能没有刷新到磁盘
28.2.2) 错误会阻止清理
即使你记得关闭资源,错误也可能阻止清理代码运行:
# 尝试处理文件
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 语句的函数会让清理变得更困难:
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 来确保清理:
# 尝试正确处理错误
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) 真实世界的影响
这些问题并非只是理论上的。想象一个要处理成千上万个文件的程序:
# 警告:资源泄漏——仅用于演示
# 问题:文件从未被关闭
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 语句结构很简单:
with expression as variable:
# 使用资源的代码块
# 缩进在 with 语句之下
# 资源在这里自动释放expression 必须求值为一个上下文管理器对象。as variable 部分是可选的,但通常会写上——它为资源提供一个名字以便引用。
28.3.2) 使用 with 进行文件操作
下面是 with 语句如何改变文件处理方式的:
# 手动方式(有风险)
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 语句中管理多个资源:
# 从一个文件读取并写入另一个文件
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 语句,但更简洁:
# 嵌套 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 模块提供了用于读写压缩文件的上下文管理器:
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 diskwith 语句确保压缩文件能被正确“收尾”,这对压缩来说至关重要——不完整的压缩可能导致文件损坏。
28.3.5) 临时切换目录
当你需要临时切换当前工作目录时,手动管理可能有风险:
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(),它是一个上下文管理器,可保证返回到原始目录:
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) 并发编程中的线程锁
在并发编程中(会在高级主题中介绍),锁也是上下文管理器:
# 概念示例(我们会在高级主题中学习 threading)
import threading
lock = threading.Lock()
# 手动管理锁(有风险)
lock.acquire()
# 临界区——如果发生错误怎么办?
lock.release() # 可能不会执行
# 使用 with 语句(安全)
with lock:
# 临界区
# 锁会自动释放,即使发生错误也是如此
pass28.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 语句时发生了什么:
with open("data.txt", "r") as file:
content = file.read()
print(content)下面是逐步执行过程:
步骤 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__():
# 发生错误时会怎样
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 时的执行流程:
关键点:异常传播之前会先调用 __exit__(),从而确保即使发生错误也会进行清理。
28.4.4) 一个简单的心智模型
把 with 语句看作一种保证:
with resource_manager as resource:
# 使用资源
pass
# Python 保证清理已经发生无论代码块中发生什么——正常完成、return 语句、异常,甚至系统错误——Python 都会调用 __exit__() 来进行清理。正是这种保证让 with 如此强大,也解释了为什么你在处理资源时应该尽量使用它。
本章关键要点:
- 上下文管理器(context manager) 为资源定义设置与清理操作
- 手动资源管理有风险,因为可能忘记清理、遇到错误或存在多个退出点
with语句保证清理一定会发生,即使出现错误也是如此- 对文件使用
with,以及对任何需要清理的资源都应使用它 - 多个资源可以在一条
with语句中进行管理 - 在幕后,
with会自动调用__enter__()和__exit__()方法 __exit__()总会运行,确保资源被正确释放
with 语句把容易出错的手动资源管理转换成自动、可靠的清理机制。无论你是在处理文件、数据库连接、锁,还是任何其他需要正确清理的资源,都应该使用它。你的代码会更安全、更整洁,也更专业。