引言
文件读写是编程中最基础也最常用的操作之一------读取配置文件、写入日志、处理 CSV 数据、保存程序状态......几乎每个程序都离不开文件操作。但很多初学者写文件代码时,要么忘记关闭文件导致资源泄漏,要么在异常时文件没关好,要么用低效的方式读取大文件导致内存爆炸。
Python 提供了优雅的解决方案:with 语句和上下文管理器。你一定见过 with open("file.txt", "r") as f: 这样的写法,但你是否真正理解它背后的原理?为什么它是最佳选择?除了文件,它还能用在哪些场景?
本文将从文件读写基础讲起,深入剖析上下文管理器的工作原理,对比各种写法的优劣,帮你写出健壮、高效、优雅的文件操作代码。
一、文件读写基础:open() 与基本操作
1.1 open() 函数与文件模式
Python 用内置函数 open() 打开文件,返回一个文件对象:
python
f = open("example.txt", "r", encoding="utf-8")
open() 的核心参数:
- file:文件路径(相对或绝对)。
- mode:打开模式,决定了能做什么操作。
- encoding :文本模式下的编码,强烈建议显式指定(如
"utf-8")。
常用模式组合:
| 模式 | 含义 | 文件不存在时 | 指针位置 | 会清空原内容吗 |
|---|---|---|---|---|
r |
只读(文本) | 报错 | 开头 | 否 |
w |
只写(文本) | 创建 | 开头 | 是 |
a |
追加写(文本) | 创建 | 末尾 | 否 |
r+ |
读写(文本) | 报错 | 开头 | 否 |
rb |
只读(二进制) | 报错 | 开头 | 否 |
wb |
只写(二进制) | 创建 | 开头 | 是 |
ab |
追加写(二进制) | 创建 | 末尾 | 否 |
python
# 文本模式:读写 str,自动编码解码
f_text = open("data.txt", "r", encoding="utf-8")
# 二进制模式:读写 bytes,不做编码转换,用于图片、视频、exe 等
f_binary = open("image.png", "rb")
1.2 读取文件的三种方式
python
# 方式 1:read() 一次性读取全部内容
with open("data.txt", "r", encoding="utf-8") as f:
content = f.read() # 返回整个文件内容的 str
print(len(content)) # 字符数
# 方式 2:readlines() 读取所有行,返回列表
with open("data.txt", "r", encoding="utf-8") as f:
lines = f.readlines() # ["第一行\n", "第二行\n", ...]
for line in lines:
print(line.strip())
# 方式 3:逐行迭代(推荐,尤其大文件)
with open("data.txt", "r", encoding="utf-8") as f:
for line in f: # 文件对象本身就是可迭代对象,逐行读取
print(line.strip())
关键区别 :read() 和 readlines() 会把整个文件加载到内存,大文件会导致内存不足;逐行迭代每次只读一行,内存占用恒定,是处理大文件的最佳方式。
1.3 写入文件
python
# write() 写入字符串
with open("output.txt", "w", encoding="utf-8") as f:
f.write("第一行\n")
f.write("第二行\n")
f.write("第三行\n")
# writelines() 写入字符串列表(不会自动加换行!)
lines = ["苹果\n", "香蕉\n", "橙子\n"]
with open("fruits.txt", "w", encoding="utf-8") as f:
f.writelines(lines)
# 追加模式:在文件末尾添加内容
with open("output.txt", "a", encoding="utf-8") as f:
f.write("追加的一行\n")
注意:writelines() 不会自动添加换行符,需要自己在每个字符串末尾加 \n。
1.4 文件指针与 seek/tell
文件操作有一个"指针",记录当前读写位置:
python
with open("data.txt", "r+", encoding="utf-8") as f:
print(f.tell()) # 0,当前指针位置(字节数)
print(f.read(5)) # 读取 5 个字符
print(f.tell()) # 指针移动了
f.seek(0) # 指针回到开头
print(f.read(5)) # 再次从头读取
seek(offset, whence):whence=0 从开头(默认),whence=1 从当前位置,whence=2 从末尾。文本模式下只支持 whence=0 的相对偏移(除了 seek(0, 2) 到末尾),二进制模式下更灵活。
二、为什么需要 with:不用 with 的问题
2.1 问题一:忘记关闭文件
最原始的写法:
python
f = open("data.txt", "r", encoding="utf-8")
content = f.read()
print(content)
f.close() # 必须手动关闭
如果 f.read() 抛出异常,f.close() 就不会执行,文件句柄泄漏。虽然 Python 的垃圾回收最终会关闭文件,但这是不确定的,且打开的文件句柄是有限资源。
2.2 问题二:异常时文件没关闭
python
try:
f = open("data.txt", "r", encoding="utf-8")
content = f.read()
# 假设这里发生了异常
1 / 0
f.close() # 这行不会执行
except ZeroDivisionError:
print("出错了")
# 文件没有被关闭!
2.3 用 try/finally 手动解决
在 with 出现之前,正确的写法是用 try/finally:
python
f = open("data.txt", "r", encoding="utf-8")
try:
content = f.read()
print(content)
finally:
f.close() # 无论是否异常都会执行
这样写虽然正确,但繁琐、容易写错,而且嵌套多层资源时代码缩进很深。
2.4 with 语句:优雅的解决方案
python
with open("data.txt", "r", encoding="utf-8") as f:
content = f.read()
print(content)
# 离开 with 块时,文件自动关闭,无论是否发生异常
with 语句等价于上面的 try/finally,但更简洁、更安全、更易读。这就是为什么 with 是文件操作的最佳选择。
三、上下文管理器原理:with 是怎么工作的
3.1 上下文管理器协议
with 语句之所以能自动管理资源,是因为文件对象实现了上下文管理器协议 ------即定义了 __enter__ 和 __exit__ 两个魔法方法。
执行 with expr as var: 时:
- 计算
expr,得到一个上下文管理器对象。 - 调用对象的
__enter__()方法,返回值赋给as后面的变量。 - 执行
with块内的代码。 - 无论块内是否发生异常,都调用对象的
__exit__(exc_type, exc_val, exc_tb)方法。 __exit__返回True表示吞掉异常,返回False/None表示正常传播异常。
3.2 自己实现一个上下文管理器
python
class MyFile:
def __init__(self, filename, mode, encoding="utf-8"):
self.filename = filename
self.mode = mode
self.encoding = encoding
self.file = None
def __enter__(self):
print("进入 with 块,打开文件")
self.file = open(self.filename, self.mode, encoding=self.encoding)
return self.file # 返回值赋给 as 后的变量
def __exit__(self, exc_type, exc_val, exc_tb):
print("离开 with 块,关闭文件")
if self.file:
self.file.close()
# exc_type: 异常类型,无异常则为 None
# exc_val: 异常对象
# exc_tb: 异常回溯信息
if exc_type is not None:
print(f"发生了异常: {exc_val}")
return False # False 表示不吞掉异常,继续传播
# 使用
with MyFile("test.txt", "w") as f:
f.write("hello world")
# 即使这里发生异常,__exit__ 也会执行
# 输出:
# 进入 with 块,打开文件
# 离开 with 块,关闭文件
3.3 contextlib:更简单的上下文管理器
手写类实现 __enter__/__exit__ 比较繁琐,Python 提供了 contextlib 模块,用生成器函数就能创建上下文管理器:
python
from contextlib import contextmanager
@contextmanager
def my_open(filename, mode, encoding="utf-8"):
# __enter__ 部分:yield 之前的代码
print("打开文件")
f = open(filename, mode, encoding=encoding)
try:
yield f # yield 的值赋给 as 后的变量
# __exit__ 部分:yield 之后的代码
finally:
print("关闭文件")
f.close()
# 使用和 open 一样
with my_open("test.txt", "w") as f:
f.write("hello")
@contextmanager 把生成器函数包装成上下文管理器:yield 之前是 __enter__,yield 的值是 as 变量,yield 之后(在 finally 中)是 __exit__。这种写法比手写类简洁得多。
3.4 上下文管理器的其他应用
上下文管理器不只能用于文件,任何需要"获取资源 → 使用 → 释放资源"的场景都适合:
python
# 1. 数据库连接(sqlite3 内置支持)
import sqlite3
with sqlite3.connect("mydb.db") as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM users")
# 离开 with 块时自动提交(或回滚)并关闭连接
# 2. 线程锁
import threading
lock = threading.Lock()
with lock:
# 自动获取锁,离开时自动释放
print("临界区代码")
# 3. 临时修改工作目录
import os
from contextlib import contextmanager
@contextmanager
def change_dir(path):
old_dir = os.getcwd()
os.chdir(path)
try:
yield
finally:
os.chdir(old_dir)
with change_dir("/tmp"):
print(os.getcwd()) # /tmp
print(os.getcwd()) # 回到原目录
# 4. 临时重定向输出
from contextlib import redirect_stdout
import io
buf = io.StringIO()
with redirect_stdout(buf):
print("这些输出会被捕获,不会打印到屏幕")
print("捕获的内容:", buf.getvalue())
3.5 同时管理多个资源
with 可以同时管理多个资源,用逗号分隔:
python
# 同时打开两个文件,一个读一个写
with open("input.txt", "r", encoding="utf-8") as fin, \
open("output.txt", "w", encoding="utf-8") as fout:
for line in fin:
fout.write(line.upper())
# 两个文件都会自动关闭
Python 3.10+ 还支持括号写法,更清晰:
python
with (
open("input.txt", "r", encoding="utf-8") as fin,
open("output.txt", "w", encoding="utf-8") as fout,
):
for line in fin:
fout.write(line.upper())
四、文件读写进阶技巧
4.1 大文件处理:逐行读取与分块读取
python
# 大文本文件:逐行读取,内存占用恒定
with open("huge.log", "r", encoding="utf-8") as f:
for line in f:
process(line) # 处理每一行
# 大二进制文件:按块读取
with open("huge.bin", "rb") as f:
while True:
chunk = f.read(8192) # 每次读 8KB
if not chunk:
break
process(chunk)
4.2 读写 JSON 文件
python
import json
# 写入 JSON
data = {"name": "小明", "age": 18, "hobbies": ["读书", "编程"]}
with open("data.json", "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
# ensure_ascii=False 让中文正常显示,indent=2 格式化缩进
# 读取 JSON
with open("data.json", "r", encoding="utf-8") as f:
data = json.load(f)
print(data["name"]) # 小明
4.3 读写 CSV 文件
python
import csv
# 写入 CSV
with open("users.csv", "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(["姓名", "年龄", "城市"])
writer.writerow(["小明", 18, "北京"])
writer.writerow(["小红", 20, "上海"])
# 读取 CSV
with open("users.csv", "r", encoding="utf-8") as f:
reader = csv.reader(f)
header = next(reader) # 跳过表头
for row in reader:
print(f"{row[0]} - {row[1]}岁 - {row[2]}")
# 用 DictReader 按列名访问
with open("users.csv", "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
print(f"{row['姓名']} - {row['年龄']}岁 - {row['城市']}")
注意:写 CSV 时加 newline="" 可以避免 Windows 下出现空行。
4.4 临时文件
python
from tempfile import NamedTemporaryFile
# 创建临时文件,关闭后自动删除
with NamedTemporaryFile(mode="w", encoding="utf-8", delete=True) as f:
f.write("临时内容")
f.flush()
# 可以通过 f.name 访问临时文件路径
print(f.name)
# 离开 with 块后临时文件自动删除
五、常见坑点
坑点 1:用 w 模式打开已存在的文件
python
# 危险!w 模式会直接清空原文件内容
with open("important.txt", "w", encoding="utf-8") as f:
f.write("新内容")
# 原内容全部丢失,无法恢复!
如果不想清空,用 r+(读写,不清空)或 a(追加)。打开前确认文件是否存在,重要文件先备份。
坑点 2:读取后忘记指针位置
python
with open("data.txt", "r", encoding="utf-8") as f:
content1 = f.read()
content2 = f.read() # 空字符串!指针已在末尾
print(len(content2)) # 0
# 解决:读取前 seek(0)
with open("data.txt", "r", encoding="utf-8") as f:
content1 = f.read()
f.seek(0)
content2 = f.read() # 再次读取全部内容
坑点 3:编码不指定导致跨平台乱码
python
# 不推荐:依赖系统默认编码,Windows 可能是 GBK,Linux 是 UTF-8
with open("data.txt", "r") as f:
content = f.read()
# 推荐:显式指定编码
with open("data.txt", "r", encoding="utf-8") as f:
content = f.read()
坑点 4:路径问题
python
import os
# 相对路径依赖当前工作目录,容易出错
with open("data.txt", "r") as f: # 相对于当前工作目录,不是脚本所在目录
pass
# 推荐:基于脚本所在目录构建绝对路径
script_dir = os.path.dirname(os.path.abspath(__file__))
file_path = os.path.join(script_dir, "data.txt")
with open(file_path, "r", encoding="utf-8") as f:
pass
# Python 3.4+ 用 pathlib 更优雅
from pathlib import Path
file_path = Path(__file__).parent / "data.txt"
with open(file_path, "r", encoding="utf-8") as f:
pass
坑点 5:二进制模式下用字符串方法
python
with open("file.txt", "rb") as f:
content = f.read() # bytes 类型
# content.strip() # bytes 有 strip,但参数要是 bytes
# content.split("\n") # 错误!要用 b"\n"
lines = content.split(b"\n") # 正确
六、最佳实践总结
- 永远用 with 打开文件 :自动关闭,异常安全,不要手动
open()+close()。 - 显式指定 encoding :文本模式下永远写
encoding="utf-8",不要依赖系统默认。 - 大文件逐行/分块读取 :不要用
read()一次性读大文件,用for line in f或分块读。 - 区分文本模式和二进制模式 :文本文件用
r/w(str),图片/视频/exe 用rb/wb(bytes)。 - 写 CSV 加 newline="":避免 Windows 下空行问题。
- JSON 用 ensure_ascii=False:让中文正常显示。
- 路径用 pathlib 或基于 file 构建:避免相对路径依赖工作目录。
- w 模式前确认文件不存在或可覆盖:重要文件先备份,避免误清空。
- 自定义资源管理用上下文管理器 :凡是"获取-使用-释放"模式,都用
with+contextlib。 - 同时管理多个资源用一个 with:逗号分隔或 Python 3.10+ 括号写法,避免嵌套。
结语
文件读写看似简单,但要写得健壮、高效、优雅并不容易。with 语句和上下文管理器是 Python 给出的优雅答案------它把"资源获取与释放"这种重复且易错的模式抽象成语法级别的支持,让我们只需关注业务逻辑。
理解了上下文管理器的 __enter__/__exit__ 协议和 contextlib 的用法,你不仅能写好文件操作,还能在数据库连接、线程锁、临时目录等各种场景中写出同样优雅的代码。