06-文件操作
Python 3.11+
为什么需要文件操作?
问题场景
程序运行时数据在内存中,关闭后就丢失了。文件是最基本的持久化手段:
# ❌ 不写文件:数据随程序退出消失
orders = [{"id": 1, "amount": 99.0}]
# ✅ 写入文件:数据持久化,下次启动可读回
from pathlib import Path
import json
Path("orders.json").write_text(
json.dumps(orders, ensure_ascii=False), encoding="utf-8"
)文件操作是日志记录、配置管理、数据导入导出等几乎所有应用场景的基础。
概念铺垫
Python 的文件 I/O 建立在 io 模块之上,这是一个分层的 I/O 系统。理解各层级的职责能帮你写出高性能的文件操作代码。
┌──────────────────────────────────────────────────────────────┐
│ Python I/O 层次结构 │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ Raw I/O (二进制底层) │ │
│ │ io.FileIO — 直接系统调用 read/write │ │
│ │ 无缓冲,直接操作文件描述符 │ │
│ └─────────────────────────────────────────┘ │
│ ↑ │
│ ┌─────────────────────────────────────────┐ │
│ │ Buffered I/O (带缓冲的二进制) │ │
│ │ io.BufferedReader / io.BufferedWriter │ │
│ │ io.BufferedRandom │ │
│ │ 默认缓冲区:DEFAULT_BUFFER_SIZE = 8192 │ │
│ │ 优化:减少系统调用次数 │ │
│ └─────────────────────────────────────────┘ │
│ ↑ │
│ ┌─────────────────────────────────────────┐ │
│ │ Text I/O (文本层) │ │
│ │ io.TextIOWrapper │ │
│ │ 编码:UTF-8/GBK 等 │ │
│ │ 换行符转换:\n ↔ os.linesep │ │
│ │ 行缓冲:每行后自动 flush │ │
│ └─────────────────────────────────────────┘ │
│ │
│ open() 的 mode 参数决定创建哪些层的组合: │
│ · 'r' → TextIOWrapper(BufferedReader(FileIO)) │
│ · 'rb' → BufferedReader(FileIO) │
│ · 'w' → TextIOWrapper(BufferedWriter(FileIO)) │
│ · 'wb' → BufferedWriter(FileIO) │
│ │
│ 文本 vs 二进制模式的关键区别: │
│ 文本模式:自动编码/解码 + 换行符转换 + 行迭代 │
│ 二进制模式:原始字节流,无编解码开销 │
│ │
└──────────────────────────────────────────────────────────────┘L1 理解层:会用
第一部分:文件读写基础
1.1 open() 函数
实际场景
在数据处理、日志记录、配置管理等场景中,都需要与文件进行交互。理解文件打开模式是进行任何文件操作的第一步。
问题:Python 的 open() 函数有哪些打开模式?如何选择合适的模式?
from typing import TextIO, BinaryIO
file: TextIO = open('file.txt', 'r', encoding='utf-8')
file_r: TextIO = open('file.txt', 'r+') # 读写,文件必须存在
file_w: TextIO = open('file.txt', 'w+') # 写读,覆盖原文件
file_rb: BinaryIO = open('image.png', 'rb') # 二进制读
file_wb: BinaryIO = open('data.bin', 'wb') # 二进制写打开模式:
| 模式 | 说明 |
|---|---|
r | 读取(默认),文件必须存在 |
w | 写入,文件不存在则创建,存在则覆盖 |
a | 追加,文件不存在则创建 |
x | 创建写入,文件已存在则报错 |
b | 二进制模式 |
t | 文本模式(默认) |
+ | 读写模式 |
1.2 with 语句
实际场景
文件资源需要正确释放,忘记关闭文件会导致资源泄漏甚至数据丢失。在异常发生时,更需要确保文件被正确关闭。
问题:为什么推荐使用 with 语句打开文件?它有什么优势?
from typing import TextIO
with open('file.txt', 'r', encoding='utf-8') as f:
content: str = f.read()
f: TextIO = open('file.txt', 'r', encoding='utf-8')
content: str = f.read()
f.close()with 语句的优势:
┌─────────────────────────────────────────────────────────────┐
│ with 语句的优势 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 自动关闭 │
│ 代码块结束后自动调用 f.close() │
│ │
│ 2. 异常安全 │
│ 即使发生异常,文件也会正确关闭 │
│ │
│ 3. 代码简洁 │
│ 不需要手动管理资源 │
│ │
└─────────────────────────────────────────────────────────────┘1.3 读取文件
实际场景
处理日志文件、配置文件、数据文件时,需要选择合适的读取方式:一次性读取、逐行读取、按字节读取。
问题:read()、readline()、readlines() 有什么区别?处理大文件应该用什么方法?
with open('file.txt', 'r') as f:
content: str = f.read()
with open('file.txt', 'r') as f:
chunk: str = f.read(1024)
with open('file.txt', 'r') as f:
line: str = f.readline()
with open('file.txt', 'r') as f:
lines: list[str] = f.readlines()
with open('file.txt', 'r') as f:
for line in f:
print(line.strip())读取配置文件示例:
def read_config(filename: str) -> dict[str, str]:
config: dict[str, str] = {}
with open(filename, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
key, value = line.split('=')
config[key.strip()] = value.strip()
return config
config: dict[str, str] = read_config('config.txt')
print(config)1.4 写入文件
实际场景
记录日志、保存数据、导出报告等场景需要写入文件内容,包括覆盖写入、追加写入、批量写入。
问题:write() 和 writelines() 有什么区别?如何追加内容到文件末尾?
with open('file.txt', 'w', encoding='utf-8') as f:
f.write('Hello, World!\n')
f.write('Python 文件操作')
lines: list[str] = ['第一行\n', '第二行\n', '第三行\n']
with open('file.txt', 'w', encoding='utf-8') as f:
f.writelines(lines)
with open('file.txt', 'a', encoding='utf-8') as f:
f.write('\n追加的内容')写入日志示例:
import datetime
def write_log(filename: str, message: str) -> None:
timestamp: str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
with open(filename, 'a', encoding='utf-8') as f:
f.write(f'[{timestamp}] {message}\n')
write_log('app.log', '用户登录成功')
write_log('app.log', '订单创建完成')1.5 文件指针
实际场景
读取文件的特定位置、实现随机访问、分块读取大文件时,需要操作文件指针。
问题:如何获取和设置文件当前位置?seek() 的三种模式有什么区别?
with open('file.txt', 'r') as f:
pos: int = f.tell()
print(f"当前位置: {pos}")
data: str = f.read(10)
print(f"读取后位置: {f.tell()}")
f.seek(0)
f.seek(20)
f.seek(10, 1)
f.seek(0, 2)seek() 参数:
| 参数值 | 含义 |
|---|---|
0 | 从文件开头计算 |
1 | 从当前位置计算 |
2 | 从文件末尾计算 |
第二部分:pathlib 路径处理
2.1 Path 对象
实际场景
传统的 os.path 操作路径需要拼接字符串,容易出错且难以跨平台。pathlib 提供了面向对象的路径操作方式。
问题:pathlib.Path 相比字符串路径有什么优势?如何使用 / 操作符拼接路径?
from pathlib import Path
p: Path = Path('data/file.txt')
p = Path('/home/user/documents')
cwd: Path = Path.cwd()
home: Path = Path.home()
p: Path = Path('data') / 'subdir' / 'file.txt'路径属性:
p: Path = Path('/home/user/documents/report.pdf')
print(p.name) # report.pdf(文件名)
print(p.stem) # report(不含扩展名)
print(p.suffix) # .pdf(扩展名)
print(p.parent) # /home/user/documents(父目录)
print(p.anchor) # /(根路径)
print(p.exists()) # 是否存在
print(p.is_file()) # 是否是文件
print(p.is_dir()) # 是否是目录
print(p.is_absolute()) # 是否是绝对路径2.2 目录操作
实际场景
创建日志目录、遍历文件系统、批量处理文件等场景需要操作目录结构。
问题:如何创建多级嵌套目录?如何递归查找特定类型的文件?
from pathlib import Path
p: Path = Path('data/output')
p.mkdir()
p: Path = Path('data/subdir/output')
p.mkdir(parents=True, exist_ok=True)
p: Path = Path('data')
for item in p.iterdir():
print(item.name, item.is_file(), item.is_dir())
p: Path = Path('data')
for txt_file in p.glob('*.txt'):
print(txt_file)
p: Path = Path('data')
for py_file in p.rglob('*.py'):
print(py_file)删除目录:
from pathlib import Path
import shutil
p: Path = Path('empty_dir')
p.rmdir()
shutil.rmtree('directory')2.3 文件操作
实际场景
使用 pathlib 可以更优雅地进行文件读写、复制、移动、删除等操作,无需手动打开关闭文件。
问题:Path 对象提供了哪些文件操作方法?如何直接读写文件内容?
from pathlib import Path
import shutil
p: Path = Path('data/file.txt')
content: str = p.read_text(encoding='utf-8')
p.write_text('Hello, World!', encoding='utf-8')
data: bytes = p.read_bytes()
p.write_bytes(b'\x00\x01\x02')
p: Path = Path('old_name.txt')
p.rename('new_name.txt')
p: Path = Path('file.txt')
p.rename(Path('new_dir/file.txt'))
shutil.copy('source.txt', 'dest.txt')
shutil.copy2('source.txt', 'dest.txt')
p: Path = Path('file.txt')
p.unlink()第三部分:异常处理
3.1 文件异常
实际场景
文件操作可能出现各种错误:文件不存在、权限不足、编码错误等。需要优雅地处理这些异常,提供友好的错误信息。
问题:文件操作可能抛出哪些异常?如何编写健壮的文件读取函数?
from pathlib import Path
def safe_read(filename: str) -> str | None:
try:
p: Path = Path(filename)
return p.read_text(encoding='utf-8')
except FileNotFoundError:
print(f"文件不存在: {filename}")
return None
except PermissionError:
print(f"无权限访问: {filename}")
return None
except UnicodeDecodeError:
print(f"编码错误: {filename}")
try:
return p.read_text(encoding='gbk')
except Exception:
return None
except IOError as e:
print(f"IO 错误: {e}")
return None异常类型:
| 异常 | 说明 |
|---|---|
FileNotFoundError | 文件不存在 |
PermissionError | 权限不足 |
IsADirectoryError | 期望文件但路径是目录 |
NotADirectoryError | 期望目录但路径是文件 |
UnicodeDecodeError | 编码错误 |
FileExistsError | 文件已存在(x 模式) |
L2 实践层:用好
推荐做法
| 做法 | 原因 | 示例 |
|---|---|---|
始终用 with 语句打开文件 | 自动关闭,异常时也不泄漏 | with open("f.txt") as f: |
明确指定 encoding="utf-8" | 默认编码因系统而异,Windows 可能是 GBK | open("f.txt", encoding="utf-8") |
优先用 pathlib 的 read_text()/write_text() | 比 open 更简洁,自动管理资源 | Path("f.txt").read_text(encoding="utf-8") |
大文件逐行迭代,不用 readlines() | readlines() 一次性加载全文到内存 | for line in f: |
写追加日志用 "a" 模式 | "w" 每次会覆盖全文,日志丢失 | open("app.log", "a", encoding="utf-8") |
创建目录加 exist_ok=True | 目录已存在不报错 | Path("out").mkdir(parents=True, exist_ok=True) |
实际应用示例
from pathlib import Path
import datetime
# 安全读取文件(带默认值)
def read_file(path: str | Path, default: str = "") -> str:
p = Path(path)
if not p.exists():
return default
return p.read_text(encoding="utf-8")
# 追加日志
def log(message: str, log_file: str = "app.log") -> None:
ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with open(log_file, "a", encoding="utf-8") as f:
f.write(f"[{ts}] {message}\n")
# 批量处理某目录下所有 txt 文件
for txt in Path("data").rglob("*.txt"):
content = txt.read_text(encoding="utf-8")
print(f"{txt.name}: {len(content)} 字符")反模式:不要这样做
# ❌ 不用 with,忘记 close() 导致文件句柄泄漏
f = open("data.txt")
content = f.read()
# 如果这里抛异常,f.close() 永远不会被调用
# ✅
with open("data.txt", encoding="utf-8") as f:
content = f.read()
# ❌ 不指定编码(在 Windows 上可能读出乱码)
content = open("中文.txt").read()
# ✅
content = open("中文.txt", encoding="utf-8").read()
# ❌ 大文件一次性读入内存
lines = open("huge.log").readlines() # 1GB 日志直接 OOM
# ✅ 逐行迭代
with open("huge.log", encoding="utf-8") as f:
for line in f:
process(line)
# ❌ 日志写入用 "w" 模式(每次覆盖)
with open("app.log", "w") as f:
f.write("新事件\n") # 历史日志全丢失
# ✅ 追加模式
with open("app.log", "a", encoding="utf-8") as f:
f.write("新事件\n")常见陷阱
| 陷阱 | 现象 | 解决方案 |
|---|---|---|
不指定 encoding | Windows 读出乱码,\u 转义字符 | 始终加 encoding="utf-8" |
用 "w" 写日志 | 每次运行历史日志被清空 | 改用 "a" 追加模式 |
readlines() 读大文件 | 内存溢出(OOM) | 用 for line in f: 逐行迭代 |
路径字符串含 \n、\t | Windows 路径 C:\new\table.txt 被转义 | 用原始字符串 r"C:\new\table.txt" 或 Path |
不捕获 FileNotFoundError | 文件不存在时程序崩溃 | 先 path.exists() 检查或 try/except |
复制文件用 read + write | 二进制文件(图片)可能损坏 | 用 shutil.copy2() |
适用场景
| 场景 | 推荐方式 | 说明 |
|---|---|---|
| 读写文本文件 | pathlib.read_text()/write_text() | 简洁,自动关闭 |
| 追加日志 | open("f", "a") + with | 不覆盖历史 |
| 大文件逐行处理 | for line in f: | 内存占用恒定 |
| 复制/移动文件 | shutil.copy2() / shutil.move() | 保留元数据 |
| 递归查找文件 | Path.rglob("*.py") | 简洁,返回 Path 对象 |
| 临时文件 | tempfile.NamedTemporaryFile() | 自动清理,不污染磁盘 |
L3 专家层:深入
Python 如何实现
Python 的 open() 函数在 CPython 中创建了 io 模块的三层包装结构。每次打开文件时:
┌──────────────────────────────────────────────────────────────┐
│ open() 内部调用链路 │
├──────────────────────────────────────────────────────────────┤
│ │
│ open("file.txt", "r") │
│ │ │
│ ├→ io.FileIO("file.txt", "r") // Raw I/O │
│ │ └→ os.open() → OS 系统调用 open() │
│ │ │
│ ├→ io.BufferedReader(FileIO) // 缓冲层 │
│ │ └→ 默认缓冲区: 8KB (8192 bytes) │
│ │ └→ 预读策略: 每次从 OS 读取缓冲区大小 │
│ │ │
│ └→ io.TextIOWrapper(BufferedReader) // 文本层 │
│ └→ 编码: UTF-8 codec → PyUnicode 对象 │
│ └→ 换行符: \n ↔ os.linesep 转换 │
│ └→ 行迭代: 按 \n 分割,延迟读取 │
│ │
│ seek() 在文本模式下有限制: │
│ · tell() 返回"不透明"位置(非字节偏移) │
│ · seek() 只能用 tell() 返回值或 0 │
│ · 原因:UTF-8 变长编码使字节偏移 ≠ 字符位置 │
│ │
│ 二进制模式跳过文本层: │
│ open("file.bin", "rb") │
│ ├→ FileIO // Raw I/O │
│ └→ BufferedReader(FileIO) // 仅缓冲 │
│ seek(0)/tell() 返回精确字节偏移 │
│ │
└──────────────────────────────────────────────────────────────┘# 验证:open() 返回的对象类型
f_text = open('/dev/null' if __import__('os').name == 'posix' else 'nul', 'r')
print(type(f_text)) # <class '_io.TextIOWrapper'>
print(type(f_text.buffer)) # <class '_io.BufferedReader'>
print(type(f_text.buffer.raw)) # <class '_io.FileIO'>
# 验证:默认缓冲区大小
import io
print(f"默认缓冲区: {io.DEFAULT_BUFFER_SIZE} bytes") # 8192
# 验证:手动控制缓冲
# buffering=0 → 无缓冲(仅二进制模式)
# buffering=1 → 行缓冲(仅文本模式)
# buffering=N → N 字节缓冲
f_unbuffered = open('/tmp/test.bin', 'wb', buffering=0)
print(type(f_unbuffered)) # <class '_io.FileIO'>(无缓冲层,直接 Raw I/O)# 验证:文本模式 vs 二进制模式性能差异
import io, timeit, tempfile, os
# 生成测试数据
data = "Hello 世界\n" * 10000
data_bytes = data.encode('utf-8')
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(data_bytes)
tmp_path = f.name
# 文本模式读取
t_text = timeit.timeit(
lambda: open(tmp_path, 'r', encoding='utf-8').read(),
number=100
)
# 二进制模式读取
t_binary = timeit.timeit(
lambda: open(tmp_path, 'rb').read(),
number=100
)
print(f"文本模式 read(): {t_text:.4f}s / 100 calls")
print(f"二进制模式 read(): {t_binary:.4f}s / 100 calls")
# 二进制模式通常快 20-40%(省去 UTF-8 解码开销)
# 验证:缓冲对性能的影响
# 无缓冲 vs 有缓冲写
t_no_buf = timeit.timeit(
lambda: open(tmp_path, 'wb', buffering=0).write(data_bytes),
number=100
)
t_with_buf = timeit.timeit(
lambda: open(tmp_path, 'wb').write(data_bytes),
number=100
)
print(f"无缓冲 write: {t_no_buf:.4f}s / 100 calls")
print(f"有缓冲 write: {t_with_buf:.4f}s / 100 calls")
# 有缓冲通常快 10-50 倍(系统调用从 N 次降为 ceil(N/8192) 次)
os.unlink(tmp_path)性能考量
| 操作 | 复杂度 | 说明 |
|---|---|---|
open() | O(1) | 单次系统调用,分配缓冲区 |
f.read() (全读) | O(n) | n=文件大小,含 decode 开销 |
f.read(N) (分块) | O(N) | 单次读取 N 字节,缓冲预读 |
for line in f: | O(n) | n=总行数+文件大小,逐行延迟解码 |
f.readline() | O(k) | k=单行长度,缓冲预读 |
f.write(s) | O(len(s)) | 缓冲后批量写入,flush 时才系统调用 |
f.seek(pos) | O(1) | lseek() 系统调用 |
f.close() | O(1) | flush 缓冲区 + close() 系统调用 |
Path.read_text() | O(n) | 等同于 open+read+close,自动管理 |
Path.write_text(s) | O(n) | 等同于 open+write+close,自动 flush |
# 验证:逐行迭代 vs readlines 内存对比
import sys
# 假设有一个 100MB 的日志文件
# for line in f: 内存占用 ≈ 缓冲大小 (8KB) + 单行长度
# f.readlines(): 内存占用 ≈ 文件总大小 (100MB)知识关联
┌──────────────────────────────────────────────────────────────┐
│ 文件操作知识关联图 │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ │
│ │ open() │────→│ io │────→│ 系统调用 │ │
│ │ 内置 │ │ 模块 │ │ open/read │ │
│ └──────────┘ └──────────┘ │ /write │ │
│ │ │ └───────────┘ │
│ │ │ │
│ ↓ ↓ │
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ │
│ │ pathlib │ │ TextI/O │ │ UTF-8 │ │
│ │ 简化API │──→ │ Wrapper │──→ │ 编解码 │ │
│ └──────────┘ └──────────┘ └───────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ │
│ │ shutil │ │ 高级 │ │ 复制/移动 │ │
│ │ │──→ │ 文件操作│──→ │ 元数据 │ │
│ └──────────┘ └──────────┘ └───────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ │
│ │ tempfile │ │ 临时 │ │ 自动清理 │ │
│ │ │──→ │ 文件 │──→ │ mkstemp │ │
│ └──────────┘ └──────────┘ └───────────┘ │
│ │
│ 选择决策: │
│ 小文本文件 → Path.read_text()(最简洁) │
│ 大文本文件 → for line in f(恒定内存) │
│ 二进制文件 → open("f", "rb")(无编解码开销) │
│ 追加日志 → open("f", "a") │
│ 临时文件 → tempfile │
│ │
│ 缓冲区设计原理: │
│ · 默认 8KB 缓冲:平衡系统调用频率和内存占用 │
│ · 系统调用成本:open/read/write 约 1-10μs │
│ · 无缓冲时:每字节一次系统调用 → 极慢 │
│ · 文本模式额外开销:UTF-8 解码 + 换行符转换 │
│ · Python 3.10+:TextIOWrapper 内部使用 io._BufferedIO │
│ 实现更高效的字符扫描 │
│ │
└──────────────────────────────────────────────────────────────┘本章小结
┌──────────────────────────────────────────────────────────────┐
│ 文件操作 知识要点 │
├──────────────────────────────────────────────────────────────┤
│ │
│ open() 模式:r 读 / w 写覆盖 / a 追加 / x 创建 / b 二进制 │
│ 始终用 with 语句:自动关闭,异常安全 │
│ 始终指定 encoding="utf-8":避免乱码 │
│ │
│ 读取方法: │
│ read() 全部读入(小文件) │
│ readline() 读一行 │
│ for line in f 逐行迭代(大文件推荐) │
│ │
│ pathlib(推荐): │
│ read_text(encoding="utf-8") 读文本 │
│ write_text(...) 写文本 │
│ rglob("*.txt") 递归查找 │
│ mkdir(parents=True,exist_ok=True) 创建目录 │
│ │
│ 异常:FileNotFoundError / PermissionError / UnicodeDecodeError │
│ │
│ L3 要点: io 三层架构 → 缓冲层 8KB → 文本模式 UTF-8 解码 │
│ │
└──────────────────────────────────────────────────────────────┘