引言
这是第七章「文件与异常处理」的第 4 篇。前 3 篇我们解决了两个问题:如何用 open 打开文件(第 1 篇《open 的本质:文件对象、模式与关闭》),以及如何用 read/write/writelines 读写内容(第 2 篇《读取与写入 API 全解》),再加上编码参数的正确用法(第 3 篇《文件编码》)。但细心的读者会发现,前面所有示例里路径都是写死的字符串:'data/logs/app.log'。一旦程序需要拼接目录、取出文件名、遍历某个目录下所有文件,字符串处理就开始变得笨拙——这正是本篇的主角 pathlib 要解决的问题。
pathlib 是 Python 标准库中面向对象的路径处理模块,从 Python 3.4 加入标准库,在 3.6 之后成为官方推荐的路径处理方式。读完本篇,你将掌握:路径对象与纯字符串路径的本质区别、PurePath/Path 的分层设计、用 / 运算符拼接路径、用 glob/rglob 遍历目录,以及 read_text/write_text 这两个最常用的快捷读写方法。你还需要具备一点前置知识:第四章第 8 篇讲过的异常处理(路径操作会失败,失败时抛 OSError 家族异常),以及第七章第 1-3 篇的 open/读写/编码基础——因为 pathlib 的快捷方法本质上就是包装了它们。
概念与原理
为什么需要路径对象:字符串路径的三大痛点
用字符串表示路径是大多数语言的传统做法,但大型项目里它暴露出三个问题。
第一个问题是拼接脆弱。'data' + '/' + name 这种写法在 Windows 上得到的是混用分隔符的路径:Windows 原生分隔符是反斜杠 \,而字符串里的 / 也会被系统接受,一旦程序在不同平台间迁移,手写分隔符的地方全部要改。第二个问题是职责错位。路径有大量语义信息:文件名叫什么(不含后缀)、后缀是什么、父目录是谁、是文件还是目录——这些本该由专门的对象负责解析,却要程序员自己写 rsplit('.') 之类的字符串操作,很容易出错(文件名里包含多个点号时尤其危险)。第三个问题是功能分散。在 pathlib 之前,路径相关函数散布在 os.path 模块里:os.path.join、os.path.basename、os.path.splitext……每个函数接受字符串、返回字符串,风格不统一。
pathlib 的解决思路是:把路径建模为对象。路径对象自带语义属性(.name、.stem、.suffix、.parent),自带操作方法(.exists()、.glob()、.read_text()),拼接用 Python 的 / 运算符,分隔符由对象内部根据操作系统自动选择。这样”路径”从被动数据变成了主动的计算主体。
PurePath 与 Path:纯计算层与系统交互层的分层
pathlib 的第一层设计是 PurePath 与 Path 的分层,这是理解整个模块的钥匙。
PurePath(纯路径)只做字符串层面的路径运算:拼接、取名字、取后缀、归一化,这些操作不访问文件系统,因此速度快、无副作用,即使路径对应的文件并不存在,也同样工作。而 Path 继承自 PurePath,在其基础上增加了与文件系统交互的方法:exists()、is_file()、glob()、read_text() 等——这些方法会真正去磁盘上查证或读写。
分层的好处是职责清晰:纯计算不依赖运行环境,可以在任何平台上安全执行;需要 I/O 的操作被统一放在 Path 层。而平台差异(Windows 用 \,POSIX 用 /)则通过选定的子类处理:PureWindowsPath 永远按 Windows 规则解析,PurePosixPath 永远按 POSIX 规则解析,PurePath 与 Path 则根据当前操作系统自动选择对应子类。在 Windows 上用 Python 时,Path 的实际类就是 WindowsPath(上篇运行环境即为 Windows,下文输出均来自本机 Python 3.12 实测)。
操作与实现
路径属性:name、stem、suffix、parent 与 parts
先用一个最小示例验证 PurePath 的分层与属性:
from pathlib import PurePath, PureWindowsPath, Path
p = PurePath('docs/2026/report.txt')
print(type(p).__name__) # 在 Windows 上自动选用 PureWindowsPath
print('name:', p.name, '| stem:', p.stem, '| suffix:', p.suffix)
print('parent:', p.parent)
print('parts:', p.parts)
w = PureWindowsPath('docs/2026/report.txt')
print('as_posix:', w.as_posix())
print('Path 的实例类型:', type(Path('x')).__name__)
PureWindowsPath
name: report.txt | stem: report | suffix: .txt
parent: docs\2026
parts: ('docs', '2026', 'report.txt')
as_posix: docs/2026/report.txt
Path 的实例类型: WindowsPath
四个最常用的属性含义固定:.name 是完整文件名;.stem 是去掉最后一个后缀后的文件名;.suffix 是最后一个后缀(含点号);.parent 是父路径。多个后缀的情况(如 archive.tar.gz),.suffix 只取最后一个,.stem 只会去掉它。.parts 则把路径按分隔符切分成元组,方便程序化处理。.as_posix() 把路径转成 POSIX 风格(正斜杠),常用于生成 URL 或跨平台传输。注意 PurePath 从不校验文件是否存在,它只负责解析字符串。
用 / 运算符拼接:比字符串加法更安全
路径拼接是日常最高频的操作,pathlib 用 / 运算符完成:
from pathlib import Path
p = Path('data') / '2026' / 'logs'
print(p) # 反斜杠连接
print(p / 'app.log') # 继续追加
print(Path('data', '2026', 'logs')) # 等价写法
print(Path.home()) # 当前用户主目录
data\2026\logs
data\2026\logs\app.log
data\2026\logs
C:\Users\Administrator
第一行输出展示了关键点:输入里写的是 /,但 Windows 上对象内部显示为 \——分隔符的选择由对象管理,写代码的人完全不用操心。/ 运算符要求左侧是 Path(或 PurePath)对象、右侧是字符串或其他同类对象,因此也不会出现”忘记补分隔符”这类字符串拼接错误。Path.home() 返回当前用户的主目录(Windows 上即 C:\Users\<用户名>),写”桌面""下载”等常用目录时可以从它出发拼接。
检查与解析:exists、is_file、is_dir 与 resolve
Path 层的方法会真实访问文件系统:
from pathlib import Path
import tempfile
with tempfile.TemporaryDirectory() as td:
root = Path(td)
cfg = root / 'config.ini'
cfg.write_text('theme=dark', encoding='utf-8')
print('存在的文件: exists =', cfg.exists(), ', is_file =', cfg.is_file())
print('目录: exists =', root.exists(), ', is_dir =', root.is_dir())
print('不存在的路径: exists =', (root / 'missing.ini').exists())
print('is_file 对目录:', root.is_file())
p = Path('C:/Windows/System32') # 注意是正斜杠输入
print('resolve 归一化:', p.resolve())
print('resolve 不需要文件存在:', Path('C:/programs_files/x/y.log').resolve())
存在的文件: exists = True , is_file = True
目录: exists = True , is_dir = True
不存在的路径: exists = False
is_file 对目录: False
resolve 归一化: C:\Windows\System32
resolve 不需要文件存在: C:\programs_files\x\y.log
exists() 判断路径是否存在;is_file() 判断是否为普通文件;is_dir() 判断是否为目录。注意三者语义不同:目录的 exists() 为 True 而 is_file() 为 False,所以判断”某路径是不是一个文件”要用 is_file() 而非 exists()。resolve() 返回规范化后的绝对路径:它把正斜杠输入转成 Windows 原生反斜杠,并展开其中的 .. 等相对成分——注意最后一行的输出,resolve 对不存在的路径也会尽力解析(不会因为目标不存在而报错),这一点常用于日志输出和去重。
遍历:glob、rglob 与 iterdir
读取一个目录下的所有文件,是脚本开发里的家常便饭。glob('*.txt') 只匹配当前层,rglob('*.txt') 递归匹配所有子层,两者都返回生成器,用 sorted() 包一层可以让输出顺序稳定:
from pathlib import Path
import tempfile
with tempfile.TemporaryDirectory() as td:
root = Path(td)
(root / 'docs' / 'guide.md').parent.mkdir(parents=True)
(root / 'docs' / 'guide.md').write_text('# 指南', encoding='utf-8')
(root / 'docs' / '图片' ).mkdir(parents=True)
(root / 'src' / 'main.py').parent.mkdir(parents=True)
(root / 'src' / 'main.py').write_text('print("hi")', encoding='utf-8')
(root / 'README.md').write_text('# 说明', encoding='utf-8')
print('--- glob("*") 只扫当前层 ---')
for p in sorted(root.glob('*')):
print(' ', p.relative_to(root))
print('--- rglob("*.md") 递归扫全部子层 ---')
for p in sorted(root.rglob('*.md')):
print(' ', p.relative_to(root))
print('--- iterdir 遍历当前层 ---')
for p in root.iterdir():
print(' ', p.name, '(目录)' if p.is_dir() else '(文件)')
--- glob("*") 只扫当前层 ---
docs
README.md
src
--- rglob("*.md") 递归扫全部子层 ---
docs\guide.md
README.md
--- iterdir 遍历当前层 ---
docs (目录)
README.md (文件)
src (目录)
三个方法的适用场景要分清:glob('*') 只列当前目录(含子目录本身);rglob('*.md') 递归找出所有 Markdown 文件,是”收集整个项目里某种文件”的标准姿势;iterdir() 不做模式匹配,直接返回当前目录全部条目。relative_to(root) 把绝对路径裁剪成相对路径,让输出可读。需要递归创建多级目录时,配合第 3 篇编码知识,用 mkdir(parents=True, exist_ok=True) 即可。
读写快捷方法:read_text 与 write_text
Path 层把最常见的”读整个文件、写整个文件”封装成了两个一行方法:
from pathlib import Path
import tempfile
with tempfile.TemporaryDirectory() as td:
p = Path(td) / 'notes.txt'
p.write_text('第一行\n第二行\n', encoding='utf-8')
print(repr(p.read_text(encoding='utf-8')))
# read_text 的内部等价写法:open + with + read
with open(p, encoding='utf-8') as f:
content = f.read()
print('与 open+with 等价:', content == p.read_text(encoding='utf-8'))
'第一行\n第二行\n'
与 open+with 等价: True
read_text(encoding=...) 内部做的事,正是第 1 篇讲过的那套流程:open 打开文件、with 保证关闭、read() 读完内容再返回;write_text 同理,内部是 open(..., 'w') + write + 关闭。既然是内部包装了 open,第 3 篇强调的编码参数在这里同样重要:不传 encoding 时使用平台默认编码,Windows 上通常是 gbk,跨机器交换文件可能出现 UnicodeDecodeError,因此显式传 encoding='utf-8' 是工程惯例。
与 os.path 的对比:字符串函数 vs 对象方法
在 pathlib 出现之前,路径处理由 os.path 模块承担,它至今仍被大量代码使用,也仍然可用。两者是同一问题的两种解法:
import os
from pathlib import Path
s = os.path.join('docs', 'report.txt') # os.path 风格:字符串
p = Path('docs') / 'report.txt' # pathlib 风格:对象
print(type(s).__name__, '->', s)
print(type(p).__name__, '->', p)
print(os.path.basename(s), '|', p.name)
print(os.path.splitext(s), '|', p.suffix)
print(os.path.exists('C:/Windows') == Path('C:/Windows').exists())
str -> docs\report.txt
WindowsPath -> docs\report.txt
report.txt | report.txt
('docs\\report', '.txt') | .txt
True
对照一目了然:os.path.join 对应 / 运算符,os.path.basename 对应 .name,os.path.splitext 对应 .stem/.suffix,os.path.exists 对应 .exists()。os.path 返回的是字符串,结果还得自己再接一次;pathlib 返回的是对象,可以继续调用方法,写法更连贯。官方文档的建议是:新代码统一用 pathlib,只有与遗留代码对接(比如第三方库只接受字符串参数)时才把 Path 转回字符串(str(p) 或 os.fspath(p))。第 6 篇《os 模块:目录操作、环境变量与系统交互》还会看到 os 模块中与 pathlib 互补的能力。
易错点与陷阱
陷阱一:把 exists() 当 is_file() 用
exists() 对文件和目录都返回 True。如果目标是”确认这是一个文件”,写成 if p.exists() 会把目录也放进来,后续 read_text 在 Windows 上会抛 PermissionError(第 5 篇会细讲),在 POSIX 上抛 IsADirectoryError。判断文件就写 p.is_file(),判断目录就写 p.is_dir(),不要用 exists() 充当两者。另外 is_file()/is_dir() 对不存在的路径返回 False 而不是抛异常——这一行为也可以用来做防御式判断。
陷阱二:glob 模式里混用反斜杠
glob/rglob 的参数是匹配模式,不是普通路径,模式内部一律用正斜杠 /,即使是在 Windows 上也是如此。root.glob('docs\\*.md') 这种写法不会报错,但结果为空——反斜杠在 glob 模式里有转义含义,不是目录分隔符。正确写法是 root.glob('docs/*.md')。同理,模式里的 * 不匹配目录分隔符,要跨目录匹配必须用 rglob 或 ** 模式。
陷阱三:resolve() 的结果与预期不一致
resolve() 会展开符号链接并归一化大小写等细节,但有两个常见误解:其一,它不要求路径存在(前面示例已验证),所以不能拿 resolve() 的成功与否来判断文件是否存在,判断存在性请用 exists();其二,在 Windows 上 resolve() 可能把没有真实文件系统对应物的路径解析出意外结果(例如网络驱动器、权限受限目录),若不需要展开符号链接、只要”取绝对路径”,用 .absolute() 更轻量。
陷阱四:mkdir 忘记 parents=True
Path.mkdir('a/b/c') 默认不递归,父目录 a/b 不存在时会抛 FileNotFoundError。工程代码里几乎总是写成 mkdir(parents=True, exist_ok=True):parents=True 递归创建全部中间目录,exist_ok=True 让目录已存在时不报错,这样同一段初始化代码可以幂等地反复执行。
小结
本篇把”路径”从字符串升级为对象。PurePath 负责纯字符串层面的解析(name/stem/suffix/parent/parts),Path 在其上加上了与文件系统交互的能力(exists/is_file/is_dir/glob/rglob/iterdir/resolve),/ 运算符让拼接告别手工补分隔符,read_text/write_text 一行完成最常见的文件读写,它们内部正是第 1-3 篇讲的 open + with + 编码参数的组合。与 os.path 的字符串函数相比,pathlib 更连贯、更安全,是官方推荐的新代码默认选择。下一章第 5 篇,我们将把这些路径操作放进异常处理的语境中,系统解决”文件操作到底会抛哪些异常、怎么写才稳健”的问题。
练习与思考题
- 用
PurePath解析'年度报告/2026-09/final_v2.tar.gz',分别打印.name、.stem、.suffix和.parent,并解释为什么后缀只显示.gz而不是.tar.gz。 - 写一个函数
collect_md(root: Path) -> list[Path],用rglob收集root下所有.md文件并返回排序后的列表;再改写为用os.walk实现,对比两种写法各自适合什么场景。 - 在临时目录里创建
a/b/c.txt(用mkdir(parents=True, exist_ok=True)),用read_text读回并验证内容一致;随后故意删掉b目录再调用read_text,观察抛出什么异常类型,并把它捕获后打印一条友好提示。 - 思考题:
p.glob('**/*.py')与p.rglob('*.py')在大多数情况下等价,但有什么细微差别?提示:从匹配范围(是否包含p自身对应的文件)入手验证。