3. 上下文管理器:with语句的原理与使用,自动管理资源

说到文件读写,有个话题绕不开——上下文管理器。说白了,就是 Python 里那个 with 语句。我刚开始学 Python 时,觉得它就是个「打开文件自动关」的语法糖。后来踩过坑才明白,这玩意儿背后的设计思想,远比我想象的深刻。

3.1 为什么需要上下文管理器?

先问一个问题:你写文件操作时,有没有忘记调用 close()?

我承认,我犯过这个错。而且不止一次。

看个反面教材:

f = open('data.txt', 'w')
f.write('Hello, World!')
# 忘了 close() —— 文件句柄泄漏了

这代码跑一次没事,跑多了呢?文件句柄会被耗尽。尤其在生产环境,一个进程能打开的文件数量是有限的。我记得有一次线上服务莫名其妙报 Too many open files,查了半天,就是某个同事在循环里打开文件没关。

你可能会说:「加个 try...finally 不就行了?」

没错,可以这样:

f = open('data.txt', 'w')
try:
    f.write('Hello, World!')
finally:
    f.close()

但每次都要写 try...finally,你不觉得烦吗?

Python 的设计者显然也觉得烦。于是就有了 with 语句。

3.2 with 语句的基本用法

用 with 改写上面的代码:

with open('data.txt', 'w') as f:
    f.write('Hello, World!')
# 退出 with 块后,文件自动关闭

简洁、清晰、安全。三行变两行,还不用操心关闭的事。

我个人习惯是:只要涉及资源获取和释放,一律用 with。文件、锁、数据库连接、网络套接字……统统交给它。

3.3 原理:上下文管理协议

with 语句能这么智能,靠的是两个魔法方法:

  • __enter__(self) —— 进入 with 块时调用,返回值赋给 as 后面的变量
  • __exit__(self, exc_type, exc_val, exc_tb) —— 退出 with 块时调用,负责清理资源

执行流程是这样的:

  1. 调用对象的 __enter__ 方法
  2. 把返回值赋给 as 后面的变量
  3. 执行 with 块内的代码
  4. 无论是否发生异常,都调用 __exit__ 方法
  5. 如果 __exit__ 返回 True,异常会被吞掉(不推荐)

我画了一张流程图,帮你理清这个逻辑:

进入 with 语句 调用 __enter__() 方法 返回值赋给 as 变量 执行 with 块内代码 调用 __exit__() 清理资源

3.4 自定义上下文管理器

你可能会问:「我能不能自己写一个支持 with 的类?」

当然可以。实现 __enter__ 和 __exit__ 就行。

举个例子,模拟一个数据库连接:

class DatabaseConnection:
    def __init__(self, host):
        self.host = host
        self.connected = False
    
    def __enter__(self):
        print(f'连接数据库 {self.host}...')
        self.connected = True
        return self  # 返回自身,供 with 块使用
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        print('关闭数据库连接...')
        self.connected = False
        # 如果返回 False,异常会继续向上传播
        # 如果返回 True,异常会被抑制
        return False
    
    def query(self, sql):
        if not self.connected:
            raise RuntimeError('数据库未连接')
        print(f'执行查询: {sql}')

# 使用
with DatabaseConnection('localhost') as db:
    db.query('SELECT * FROM users')
# 自动关闭连接

输出:

连接数据库 localhost...
执行查询: SELECT * FROM users
关闭数据库连接...

嗯,这里要注意:__exit__ 的返回值很关键。返回 False 表示异常继续传播,返回 True 表示异常已被处理。我建议除非你有特殊需求,否则一律返回 False。吞异常是个坏习惯。

3.5 使用 contextlib 简化

每次写一个类来实现上下文管理器,有点重。Python 标准库提供了 contextlib,可以更轻量地实现。

我最常用的是 @contextmanager 装饰器:

from contextlib import contextmanager

@contextmanager
def database_connection(host):
    print(f'连接数据库 {host}...')
    # 在 yield 之前是 __enter__
    conn = {'host': host, 'connected': True}
    try:
        yield conn  # 把 conn 返回给 as 变量
    finally:
        # 在 yield 之后是 __exit__
        print('关闭数据库连接...')
        conn['connected'] = False

# 使用
with database_connection('localhost') as db:
    print(f'已连接: {db["connected"]}')

输出:

连接数据库 localhost...
已连接: True
关闭数据库连接...

你看,用 yield 把代码分成两段:前半段是进入时的操作,后半段是退出时的清理。简洁多了。

💡 小技巧: 如果你只需要临时重定向输出或修改某个属性,可以用 contextlib.redirect_stdout() 或自己写一个简单的上下文管理器。我在调试时经常用这个来捕获打印输出。

3.6 多资源管理

有时候需要同时管理多个资源。比如同时打开两个文件,把一个的内容复制到另一个:

# 错误写法:嵌套太深
with open('source.txt', 'r') as src:
    with open('dest.txt', 'w') as dst:
        dst.write(src.read())

# 正确写法:用逗号分隔
with open('source.txt', 'r') as src, open('dest.txt', 'w') as dst:
    dst.write(src.read())

Python 3.1 之后支持这种写法。多个资源用逗号隔开,with 会依次调用它们的 __enter__,退出时按相反顺序调用 __exit__。

我曾经在项目中用这个特性同时管理文件锁和文件句柄:

import threading

file_lock = threading.Lock()

with file_lock, open('shared.txt', 'a') as f:
    f.write('线程安全的写入\n')

锁和文件都自动管理,省心不少。

3.7 避坑指南

⚠️ 我曾经踩过的坑:
  • 不要吞异常:__exit__ 返回 True 会抑制异常,这可能导致 bug 难以排查。除非你明确知道自己在做什么,否则返回 False。
  • 注意资源顺序:多个资源时,退出顺序与进入顺序相反。如果资源之间有依赖关系,要小心。
  • 不要滥用:with 适用于资源管理,不要用它来替代普通的代码块。我见过有人用 with 来包裹一段逻辑,只是为了「看起来优雅」——没必要。

3.8 总结一下

概念 说明
with 语句 自动管理资源的语法糖,确保退出时清理
__enter__ 进入时调用,返回值赋给 as 变量
__exit__ 退出时调用,负责清理资源
@contextmanager 用生成器快速创建上下文管理器
多资源管理 用逗号分隔多个资源,按序进入、逆序退出

说白了,with 语句就是 Python 帮你写好了 try...finally。你只管用,它管收尾。这种「约定优于配置」的设计,正是 Python 优雅哲学的体现。

下次写文件操作时,记得用 with。你的代码会感谢你的。

可可精品资料库