Python 文件读写与上下文管理器:with 语句为什么是最佳选择

引言

文件读写是编程中最基础也最常用的操作之一------读取配置文件、写入日志、处理 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: 时:

  1. 计算 expr,得到一个上下文管理器对象。
  2. 调用对象的 __enter__() 方法,返回值赋给 as 后面的变量。
  3. 执行 with 块内的代码。
  4. 无论块内是否发生异常,都调用对象的 __exit__(exc_type, exc_val, exc_tb) 方法。
  5. __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")  # 正确

六、最佳实践总结

  1. 永远用 with 打开文件 :自动关闭,异常安全,不要手动 open() + close()
  2. 显式指定 encoding :文本模式下永远写 encoding="utf-8",不要依赖系统默认。
  3. 大文件逐行/分块读取 :不要用 read() 一次性读大文件,用 for line in f 或分块读。
  4. 区分文本模式和二进制模式 :文本文件用 r/w(str),图片/视频/exe 用 rb/wb(bytes)。
  5. 写 CSV 加 newline="":避免 Windows 下空行问题。
  6. JSON 用 ensure_ascii=False:让中文正常显示。
  7. 路径用 pathlib 或基于 file 构建:避免相对路径依赖工作目录。
  8. w 模式前确认文件不存在或可覆盖:重要文件先备份,避免误清空。
  9. 自定义资源管理用上下文管理器 :凡是"获取-使用-释放"模式,都用 with + contextlib
  10. 同时管理多个资源用一个 with:逗号分隔或 Python 3.10+ 括号写法,避免嵌套。

结语

文件读写看似简单,但要写得健壮、高效、优雅并不容易。with 语句和上下文管理器是 Python 给出的优雅答案------它把"资源获取与释放"这种重复且易错的模式抽象成语法级别的支持,让我们只需关注业务逻辑。

理解了上下文管理器的 __enter__/__exit__ 协议和 contextlib 的用法,你不仅能写好文件操作,还能在数据库连接、线程锁、临时目录等各种场景中写出同样优雅的代码。

相关推荐
Shawn Dev1 小时前
没有公网 IP,如何让家里的服务器通过域名访问?——Cloudflare Tunnel 实战指南
服务器·网络协议·tcp/ip
智者向内寻求力量1 小时前
【记录】ubuntu 24.04 安装在levnoval PC上之后,蓝牙设备找不到解决方法
linux·服务器·ubuntu
无望__wsk1 小时前
k8s控制器
java·docker·kubernetes
whcyhhh1 小时前
头歌实践教学平台:数据科学与大数据技术导论(七下)
大数据·数据库·python
迪康coolmu1 小时前
企业IT运维闭环——工单管理与远程协助一体化实践
java·大数据·运维·网络·数据库·人工智能·安全
Java后端的Ai之路1 小时前
05、Python单例模式完全指南
开发语言·人工智能·python·单例模式·oracle
halo雨航1 小时前
阿贝云免费服务器申请
运维·服务器
李可以量化1 小时前
Redis Client 从入门到精通(一)下:redis-py 核心数据结构操作与连接管理
数据结构·redis·python·bootstrap·量化交易·qmt