AI 技术博客
返回首页
Python 基础 20 分钟阅读

pathlib 入门:路径对象、拼接与遍历

Pythonpathlib路径文件系统

引言

这是第七章「文件与异常处理」的第 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 篇,我们将把这些路径操作放进异常处理的语境中,系统解决”文件操作到底会抛哪些异常、怎么写才稳健”的问题。

练习与思考题

  1. 用 PurePath 解析 '年度报告/2026-09/final_v2.tar.gz',分别打印 .name、.stem、.suffix 和 .parent,并解释为什么后缀只显示 .gz 而不是 .tar.gz。
  2. 写一个函数 collect_md(root: Path) -> list[Path],用 rglob 收集 root 下所有 .md 文件并返回排序后的列表;再改写为用 os.walk 实现,对比两种写法各自适合什么场景。
  3. 在临时目录里创建 a/b/c.txt(用 mkdir(parents=True, exist_ok=True)),用 read_text 读回并验证内容一致;随后故意删掉 b 目录再调用 read_text,观察抛出什么异常类型,并把它捕获后打印一条友好提示。
  4. 思考题:p.glob('**/*.py') 与 p.rglob('*.py') 在大多数情况下等价,但有什么细微差别?提示:从匹配范围(是否包含 p 自身对应的文件)入手验证。