各位有没有写过这样的代码? ```python import os base_dir = os.path.dirname(os.path.abspath(__file__)) data_path = os.path.join(base_dir, 'data', 'sub', 'foo.txt') if os.path.exists(data_path) and os.path.isfile(data_path): with open(data_path, 'r', encoding='utf-8') as f: content = f.read() print(content) ``` 是不是看着头大?一个 `os.path.dirname` 套一个 `os.path.abspath`,再嵌一个 `os.path.join`,路径就这么一层一层包出来了。要打开还得 `with open(...) as f:`,要判断还得 `os.path.exists` 加 `os.path.isfile`,方法散落在 `os` 和 `os.path` 两个模块里,还要再加一个 `open` 内建函数。光是数函数名就数得我水哥头都晕。 更要命的是,路径在这套写法里只是个普通字符串。字符串就是字符串,它不知道自己代表的是文件还是目录,更不可能自己跑去判断「我存在吗」、「我是什么后缀」。所有这些操作都得拿着字符串去喂给一堆函数,函数再吐回字符串,字符串再传给下一个函数 …… 整个过程像极了车间里搬砖头的流水线。 那么有没有一种写法,让路径自己「活」起来呢?让 `Path('data/foo.txt')` 这种东西本身就知道自己是不是文件、能不能被读取、有什么后缀名? 有的,就是今天要聊的 `pathlib`。这是 Python 3.4 加进来的标准库,专门用来收拾上面这种乱糟糟的路径代码。从 Python 3.6 开始,标准库里大部分接受路径字符串的函数也都能直接吃 `Path` 对象,所以基本可以无痛切过来。 写到现在 Python 都 3.13 了,各位童鞋如果还在 `os.path.join` 一条道走到黑,那真的是亏大了。本文就跟着水哥一起,把 `pathlib` 这套现代 API 从头到尾捋一遍。 ## 一、第一个 Path 先来认识一下主角: ```python from pathlib import Path p = Path('data/foo.txt') print(p) print(type(p)) ``` 输出可能长这样(macOS / Linux 上): ``` data/foo.txt ``` 是不是发现什么不对?我们明明是 `Path('data/foo.txt')`,怎么打印出来类型是 `PosixPath`? 这是因为 `Path` 是个聪明家伙,它会根据你当前的操作系统,自动给你返回对应平台的子类: - 在 macOS / Linux 上,返回 `PosixPath` - 在 Windows 上,返回 `WindowsPath` 各位平时基本不用关心这个,直接用 `Path` 就完事了,需要跨平台细节的时候再说。 除了 `Path('字符串')` 这种基础用法,还有两个非常常用的「快捷入口」: ```python from pathlib import Path print(Path.home()) print(Path.cwd()) ``` 输出: ``` /Users/two_water /Users/two_water/projects/demo ``` `Path.home()` 是当前用户的家目录,`Path.cwd()` 是「当前工作目录」(current working directory)。这两个相当于以前的 `os.path.expanduser('~')` 和 `os.getcwd()`,不过显然新写法看着更清爽,是不是? ## 二、用 `/` 来拼接路径 路径拼接是最常见的操作,没有之一。看看老写法: ```python import os base = '/tmp' data_path = os.path.join(base, 'demo', 'sub', 'foo.txt') print(data_path) ``` 输出: ``` /tmp/demo/sub/foo.txt ``` 这个 `os.path.join` 用是好用,但写起来嘴里得念好几遍 `os` `path` `join`,手指都不想动了。 然后看看 `pathlib` 是怎么玩的: ```python from pathlib import Path base = Path('/tmp') data_path = base / 'demo' / 'sub' / 'foo.txt' print(data_path) ``` 输出: ``` /tmp/demo/sub/foo.txt ``` 注意看:拼接路径用的是 `/` 这个操作符。是不是有点神奇? 为什么啊?因为 `Path` 重载了 `__truediv__`(也就是除法操作符),所以 `Path('/tmp') / 'demo'` 这种写法就被翻译成了路径拼接。这种设计简直是天才,因为路径在 URL、Linux 文件系统里用的本来就是 `/`,跟我们脑子里的语义完全一致。 来个对比表,看看是不是清爽多了: | 老写法 | 新写法 | | --- | --- | | `os.path.join(a, b, c)` | `Path(a) / b / c` | | `os.path.join(os.path.dirname(__file__), 'data')` | `Path(__file__).parent / 'data'` | | `os.path.expanduser('~/Documents')` | `Path.home() / 'Documents'` | 而且 `/` 还可以拿一个 `Path` 跟字符串混着用,结果都是 `Path` 对象: ```python from pathlib import Path p1 = Path('/tmp') / 'foo' p2 = Path('/tmp') / Path('foo') p3 = 'foo' / Path('tmp') print(p1) print(p2) print(p3) ``` 输出: ``` /tmp/foo /tmp/foo foo/tmp ``` 不管 `/` 左边还是右边是字符串,只要有一边是 `Path`,结果就还是 `Path`。 ## 三、常用属性,一锅端 各位写过爬虫或者文件处理代码的话,对「拿到一个路径,我要它的文件名」、「我要它的扩展名」这种需求肯定不陌生。老写法散布在 `os.path.basename`、`os.path.splitext` 这些函数里,记起来挺麻烦。 `Path` 把这些都做成属性了,一个个看。 ### 3.1 `.name`:完整文件名(带后缀) ```python from pathlib import Path p = Path('/tmp/demo/foo.txt') print(p.name) ``` 输出: ``` foo.txt ``` 相当于以前的 `os.path.basename(...)`。 ### 3.2 `.stem`:去掉后缀的「主干」 ```python from pathlib import Path p = Path('/tmp/demo/foo.txt') print(p.stem) ``` 输出: ``` foo ``` 这个就是文件名去掉扩展名的部分,特别适合用来生成「同名但换后缀」的新文件,比如 `foo.txt` 转 `foo.json`。 ### 3.3 `.suffix`:扩展名(带点) ```python from pathlib import Path p = Path('/tmp/demo/foo.txt') print(p.suffix) ``` 输出: ``` .txt ``` 注意是带点的。如果你需要不带点的,自己 `[1:]` 切一下就好。 那么如果是 `foo.tar.gz` 这种多后缀的呢? ```python from pathlib import Path p = Path('archive.tar.gz') print(p.suffix) print(p.suffixes) ``` 输出: ``` .gz .tar.gz ``` 看到了吗?`.suffix` 只给最后一个,`.suffixes` 给一个列表。 ### 3.4 `.parent`:父目录 ```python from pathlib import Path p = Path('/tmp/demo/sub/foo.txt') print(p.parent) ``` 输出: ``` /tmp/demo/sub ``` 相当于 `os.path.dirname(...)`,但读起来自然多了。 ### 3.5 `.parents`:所有祖先目录 ```python from pathlib import Path p = Path('/tmp/demo/sub/foo.txt') for ancestor in p.parents: print(ancestor) ``` 输出: ``` /tmp/demo/sub /tmp/demo /tmp / ``` `.parents` 返回的是一个序列,从最近的父目录开始一路向上找祖先。可以用 `p.parents[0]`、`p.parents[1]` 这种下标访问,也可以 `for` 循环。 ### 3.6 `.anchor`:锚点 锚点是路径的「根」部分。 ```python from pathlib import Path p = Path('/tmp/demo/foo.txt') print(p.anchor) ``` 输出: ``` / ``` 在 Linux / macOS 上一般就是 `/`,在 Windows 上可能是 `C:\` 这种盘符。这个属性平时用得不多,但跨平台代码里偶尔会派上用场。 ### 3.7 综合演示 各位看一个综合例子,把所有属性串起来感受一下: ```python from pathlib import Path p = Path('/Users/two_water/projects/demo/main.py') print('name :', p.name) print('stem :', p.stem) print('suffix :', p.suffix) print('parent :', p.parent) print('anchor :', p.anchor) print('parts :', p.parts) ``` 输出: ``` name : main.py stem : main suffix : .py parent : /Users/two_water/projects/demo anchor : / parts : ('/', 'Users', 'two_water', 'projects', 'demo', 'main.py') ``` 最后多送的一个 `.parts`,把整个路径切成元组,方便逐段处理。是不是发现路径在 `pathlib` 里彻底不是字符串了,它是一个有属性、有方法的「对象」? ## 四、判断和信息 光能拼路径还不够,我们经常要判断「这文件存不存在啊」、「是文件还是目录啊」。 老写法: ```python import os p = '/tmp' if os.path.exists(p): if os.path.isdir(p): print('是目录') elif os.path.isfile(p): print('是文件') ``` 新写法: ```python from pathlib import Path p = Path('/tmp') if p.exists(): if p.is_dir(): print('是目录') elif p.is_file(): print('是文件') ``` 这两段长度差不多,但意思就完全不一样了。新写法里,`p` 自己知道「我存不存在」、「我是文件还是目录」,方法直接挂在对象上。老写法里 `p` 只是个字符串,所有判断都得拿到 `os.path` 模块里去查。 下面把常用的判断方法列一下: | 方法 | 含义 | | --- | --- | | `.exists()` | 路径是否存在 | | `.is_file()` | 是不是普通文件 | | `.is_dir()` | 是不是目录 | | `.is_symlink()` | 是不是软链接 | | `.is_absolute()` | 是不是绝对路径 | 注意 `.is_file()` 和 `.is_dir()` 都隐含了「存在」这个条件,所以一般不用先调 `.exists()`。除非你想区分「不存在」和「存在但不是文件」这两种情况。 来个真实例子: ```python from pathlib import Path p = Path('/tmp') print('exists :', p.exists()) print('is_dir :', p.is_dir()) print('is_file :', p.is_file()) ``` `/tmp` 在 macOS / Linux 上是一定存在的目录,所以输出会是: ``` exists : True is_dir : True is_file : False ``` ### 4.1 拿到详细信息:`.stat()` 如果想知道文件大小、修改时间这些更详细的信息,用 `.stat()`: ```python from pathlib import Path import datetime p = Path('/tmp') info = p.stat() print('size :', info.st_size, 'bytes') print('mtime ts :', info.st_mtime) print('mtime str :', datetime.datetime.fromtimestamp(info.st_mtime)) ``` `.stat()` 返回的是一个 `os.stat_result` 对象,常用字段有: - `.st_size`:文件大小(字节) - `.st_mtime`:最后修改时间(Unix 时间戳) - `.st_ctime`:创建时间(具体含义因系统而异) - `.st_mode`:权限位 时间戳是浮点数,要变成可读时间,可以用 `datetime.datetime.fromtimestamp(...)` 转一下。 ## 五、遍历目录 各位有没有写过「找出某个文件夹下所有 `.py` 文件」这种代码?老写法基本得 `os.walk`,写起来有点费劲。`pathlib` 给了三个利器。 ### 5.1 `.iterdir()`:只列当前目录下一层 ```python from pathlib import Path root = Path('/tmp') for child in root.iterdir(): print(child) ``` `/tmp` 下一层的所有内容(文件 + 目录)都会被列出来。注意 `.iterdir()` 不递归,只看当前目录这一层。 如果只想要文件,加个判断: ```python from pathlib import Path root = Path('/tmp') files = [c for c in root.iterdir() if c.is_file()] print(f'共有 {len(files)} 个文件') ``` ### 5.2 `.glob(pattern)`:匹配模式(仅当前目录) `.glob` 用的是 shell 那种通配符,星号 `*` 代表任意字符(不包括路径分隔符),问号 `?` 代表单个字符。 ```python from pathlib import Path root = Path('/tmp') for txt in root.glob('*.txt'): print(txt) ``` 这段代码会列出 `/tmp` 目录下所有以 `.txt` 结尾的文件。但 `.txt` 在子目录里的不会被找到。 ### 5.3 `.rglob(pattern)`:递归匹配 `.rglob` 是 `.glob('**/' + pattern)` 的简写,意思是「递归地在所有子目录里找」。 ```python from pathlib import Path root = Path('/tmp') md_files = list(root.rglob('*.md')) print(f'找到 {len(md_files)} 个 markdown 文件') ``` 这段代码会把 `/tmp` 目录下、所有子孙目录里的 `.md` 文件全找出来。 ### 5.4 实战:找出某目录下所有 Python 文件 来个综合例子,找出某目录下所有 `.py` 文件并统计: ```python from pathlib import Path root = Path.cwd() py_files = list(root.rglob('*.py')) print(f'在 {root} 下找到 {len(py_files)} 个 .py 文件') for f in py_files[:5]: print(' -', f.relative_to(root)) ``` `Path.cwd()` 是当前工作目录,`.rglob('*.py')` 递归找所有 `.py`。`.relative_to(root)` 是把绝对路径转成相对路径,看起来更清爽。 各位自己跑一下,应该会看到当前项目里的 `.py` 文件列表。这种写法是不是比 `os.walk` 加 `endswith('.py')` 那一套清爽不止一截? ## 六、读写文件 这是 `pathlib` 最让我感动的功能之一。 老写法读文件: ```python with open('/tmp/foo.txt', 'r', encoding='utf-8') as f: content = f.read() print(content) ``` 老写法写文件: ```python with open('/tmp/foo.txt', 'w', encoding='utf-8') as f: f.write('hello two_water') ``` `with open(...) as f:` 这一坨已经成了 Python 的「肌肉记忆」,但说实话,要读取一个文件的全部内容,还要写这么一行 + 缩进一行,是不是有点 …… 啰嗦? ### 6.1 `.read_text()` 一行读完 ```python from pathlib import Path p = Path('/tmp/two_water_demo.txt') p.write_text('hello two_water', encoding='utf-8') content = p.read_text(encoding='utf-8') print(content) ``` 输出: ``` hello two_water ``` 是不是清爽?`.read_text()` 直接把整个文件读成字符串,不用 `with`、不用 `open`、不用 `f.read()`。 ### 6.2 `.write_text()` 一行写完 ```python from pathlib import Path p = Path('/tmp/two_water_demo.txt') p.write_text('两点水的打卡记录\n', encoding='utf-8') print(p.read_text(encoding='utf-8')) ``` `.write_text(s)` 会把字符串 `s` 写入文件,如果文件已经存在,会被覆盖(注意,是覆盖不是追加)。返回值是写入的字符数。 ### 6.3 二进制读写:`.read_bytes()` 和 `.write_bytes()` 文本之外,二进制也有对应方法: ```python from pathlib import Path p = Path('/tmp/two_water_bin.dat') p.write_bytes(b'\x00\x01\x02two_water') data = p.read_bytes() print(data) print(len(data), 'bytes') ``` 输出: ``` b'\x00\x01\x02two_water' 12 bytes ``` `.read_bytes()` 和 `.write_bytes()` 处理的是 `bytes` 对象,不需要也不能传 `encoding`。 ### 6.4 什么时候还需要 `open()`? 那是不是有了 `read_text` / `write_text`,`open` 就没用了? 也不是。当你要做下面这些事情的时候,还得用传统的 `with open(...) as f:`: - 读超大文件,需要逐行读,避免一次读到内存里 - 需要追加模式(`'a'`) - 需要在写入过程中做复杂逻辑(比如边读边算边写) 但好消息是,`Path` 对象也提供了 `.open()` 方法,可以直接用: ```python from pathlib import Path p = Path('/tmp/two_water_lines.txt') p.write_text('line1\nline2\nline3\n', encoding='utf-8') with p.open('r', encoding='utf-8') as f: for line in f: print(line.rstrip()) ``` 输出: ``` line1 line2 line3 ``` `p.open(...)` 跟内建 `open(p, ...)` 完全等价,但更顺手——所有文件操作都从 `p` 这个对象出发。 ## 七、创建和删除 创建目录、创建空文件、删除文件、删除目录,这都是经常要做的事。`pathlib` 都准备好了。 ### 7.1 `.mkdir()` 创建目录 ```python from pathlib import Path p = Path('/tmp/two_water_demo_dir') p.mkdir(exist_ok=True) print(p.exists(), p.is_dir()) ``` 输出: ``` True True ``` `exist_ok=True` 这个参数特别有用:如果目录已经存在,不会报错;如果设成 `False`(默认),目录已存在就会抛 `FileExistsError`。 那如果父目录都不存在呢?比如要建 `/tmp/a/b/c`,但 `a` 和 `b` 都还没有: ```python from pathlib import Path p = Path('/tmp/two_water_a/b/c') p.mkdir(parents=True, exist_ok=True) print(p.exists()) ``` `parents=True` 就是「如果父目录不存在,一路递归创建」,等价于 shell 里的 `mkdir -p`。 记住这个组合拳——`mkdir(parents=True, exist_ok=True)`,写脚本的时候几乎闭着眼睛就能用。 ### 7.2 `.touch()` 创建空文件 ```python from pathlib import Path p = Path('/tmp/two_water_demo.empty') p.touch(exist_ok=True) print(p.exists(), p.is_file(), p.stat().st_size) ``` 输出大概是: ``` True True 0 ``` `.touch()` 类似 shell 里的 `touch` 命令,文件不存在就创建一个空文件,存在就更新它的修改时间。 ### 7.3 `.unlink()` 删除文件 ```python from pathlib import Path p = Path('/tmp/two_water_demo.empty') if p.exists(): p.unlink() print('after unlink:', p.exists()) ``` 输出: ``` after unlink: False ``` `.unlink()` 删的是「单个文件或软链接」。如果文件不存在会报 `FileNotFoundError`,可以用 `missing_ok=True`(Python 3.8+)来避免: ```python from pathlib import Path p = Path('/tmp/two_water_does_not_exist.x') p.unlink(missing_ok=True) print('done') ``` ### 7.4 `.rmdir()` 删除空目录 ```python from pathlib import Path p = Path('/tmp/two_water_demo_dir') p.mkdir(exist_ok=True) p.rmdir() print('after rmdir:', p.exists()) ``` 注意:`.rmdir()` 只能删「空目录」。要是目录里还有内容,会抛 `OSError`。 那要删非空目录怎么办?`pathlib` 自己没有提供,得靠标准库 `shutil`: ```python import shutil from pathlib import Path p = Path('/tmp/two_water_demo_full') p.mkdir(exist_ok=True) (p / 'child.txt').write_text('hi', encoding='utf-8') shutil.rmtree(p) print('after rmtree:', p.exists()) ``` 输出: ``` after rmtree: False ``` `shutil.rmtree` 是「连内容带目录一起删」,相当于 `rm -rf`。各位用这个的时候千万看清路径,别一不小心 `rmtree('/')` 把家给端了。 ## 八、路径转换 实际项目里,路径有「相对」和「绝对」两种形态,经常需要互相转换。 ### 8.1 `.absolute()`:转成绝对路径(不解析符号链接) ```python from pathlib import Path p = Path('foo.txt') print(p) print(p.absolute()) ``` 输出大概长这样(取决于你当前在哪): ``` foo.txt /Users/two_water/projects/demo/foo.txt ``` 注意 `.absolute()` 不要求文件真的存在,它只是把相对路径拼到当前工作目录前面,得到一个绝对形式。 ### 8.2 `.resolve()`:转成绝对路径(并解析符号链接、`..` 等) ```python from pathlib import Path p = Path('foo/../bar/./baz.txt') print(p) print(p.resolve()) ``` 输出(在 `/tmp` 下跑): ``` foo/../bar/./baz.txt /tmp/bar/baz.txt ``` 看到了吧?`.resolve()` 会把 `..` 和 `.` 这种相对引用全部「化简」掉,得到一个干净的绝对路径。它还会跟着符号链接走到真实位置。 那 `.absolute()` 和 `.resolve()` 啥时候用哪个呢?记住一条粗糙但够用的规则:**默认就用 `.resolve()`**。它更彻底,结果更干净。只有你明确不想跟随软链、不想化简 `..` 的时候,才用 `.absolute()`。 ### 8.3 `.relative_to(other)`:算相对路径 ```python from pathlib import Path base = Path('/tmp') file = Path('/tmp/demo/sub/foo.txt') print(file.relative_to(base)) ``` 输出: ``` demo/sub/foo.txt ``` 这个特别适合用来打日志、做展示,比如打印一个项目里所有文件的相对路径,看着比绝对路径舒服一万倍。 注意 `.relative_to(other)` 要求当前路径必须是 `other` 的子孙,否则会抛 `ValueError`。 ### 8.4 `.expanduser()`:把 `~` 展开成家目录 各位写脚本经常会接一个用户输入的路径,比如配置文件里写着 `~/.config/myapp/conf.toml`。这个 `~` 是 shell 里的「家目录」简写,但 Python 不会自动展开它,得自己来: ```python from pathlib import Path p = Path('~/Documents/foo.txt') print(p) print(p.expanduser()) ``` 输出(具体家目录因人而异): ``` ~/Documents/foo.txt /Users/two_water/Documents/foo.txt ``` 这个 `.expanduser()` 相当于以前的 `os.path.expanduser(...)`,处理用户输入的路径基本必备。 ### 8.5 一个常见组合拳:`expanduser().resolve()` ```python from pathlib import Path raw = '~/Desktop/../Desktop/foo.txt' p = Path(raw).expanduser().resolve() print(p) ``` 先展 `~`,再 `resolve` 化简和取绝对路径,最后得到一个干净规整的绝对路径。这种写法在「读用户配置」这种场景非常顺手。 ## 九、改个名字:`with_name` / `with_stem` / `with_suffix` 各位有没有这种需求?拿到一个 `foo.txt`,想生成同目录下的 `foo.json`、或者把 `report_v1.md` 改成 `report_v2.md`。 老写法基本得字符串切片加 `os.path.join`,写起来贼丑。`pathlib` 给了三个非常贴心的方法。 ### 9.1 `.with_suffix(new_suffix)` 换后缀 ```python from pathlib import Path p = Path('/tmp/foo.txt') print(p.with_suffix('.json')) print(p.with_suffix('')) ``` 输出: ``` /tmp/foo.json /tmp/foo ``` 注意 `new_suffix` 必须以 `.` 开头(或者是空字符串,表示去掉后缀)。这个方法非常适合做「同名换格式」的需求,比如批量把 `.md` 转成 `.html`: ```python from pathlib import Path src = Path('/tmp/two_water_doc.md') src.write_text('# 标题', encoding='utf-8') dst = src.with_suffix('.html') print('源文件:', src) print('目标:', dst) ``` 输出: ``` 源文件: /tmp/two_water_doc.md 目标: /tmp/two_water_doc.html ``` ### 9.2 `.with_name(new_name)` 换整个文件名 `.with_name(...)` 会把最后那一段(包括后缀)整个换掉: ```python from pathlib import Path p = Path('/tmp/demo/foo.txt') print(p.with_name('bar.md')) ``` 输出: ``` /tmp/demo/bar.md ``` ### 9.3 `.with_stem(new_stem)` 换主干(保留后缀) 这个是 Python 3.9 才加的,专门用来「保留后缀,只换主干」: ```python from pathlib import Path p = Path('/tmp/demo/foo.txt') print(p.with_stem('bar')) ``` 输出: ``` /tmp/demo/bar.txt ``` 后缀 `.txt` 保留不变,主干 `foo` 换成 `bar`。 各位想想,要是没有 `.with_stem`,干这件事得手动 `p.with_name(new_stem + p.suffix)`,多出一步拼接。Python 标准库的设计者很贴心是不是? ### 9.4 综合演示 来一个把 `report_v1.md` 改成 `report_v2.json` 的例子: ```python from pathlib import Path p = Path('/tmp/report_v1.md') new = p.with_stem('report_v2').with_suffix('.json') print(new) ``` 输出: ``` /tmp/report_v2.json ``` 链式调用,一气呵成。 ## 十、跨平台:`PurePath` 系列 各位有没有遇到过这种情况:在 macOS 上写好的脚本,扔到同事 Windows 上跑就崩了?很多时候就栽在路径分隔符上——macOS / Linux 用 `/`,Windows 用 `\`。 `pathlib` 把这件事处理得相当优雅。它有两条继承线: - 「具体路径」:`Path`(自动选)、`PosixPath`、`WindowsPath`——能真的去访问文件系统 - 「纯路径」:`PurePath`、`PurePosixPath`、`PureWindowsPath`——只做字符串层面的路径操作,不碰文件系统 平时各位 99% 的时间都用 `Path` 就够了。但偶尔,比如你在 Linux 上要解析一段 Windows 风格的路径字符串,就会需要 `PureWindowsPath`: ```python from pathlib import PureWindowsPath, PurePosixPath p = PureWindowsPath(r'C:\Users\two_water\foo.txt') print(p.name) print(p.parent) q = PurePosixPath('/home/two_water/foo.txt') print(q.name) print(q.parent) ``` 输出: ``` foo.txt C:\Users\two_water foo.txt /home/two_water ``` 可以看到,`PureWindowsPath` 哪怕在 macOS 上跑,也按 Windows 的方式解析路径;`PurePosixPath` 反之。这两个东西不能调用 `.exists()`、`.read_text()` 这种「需要真访问文件系统」的方法,但用来做「路径字符串解析」绰绰有余。 跨平台代码这块就先点到为止,绝大多数童鞋日常用 `Path` 就够了。 ## 十一、老写法对照表 + 常见踩坑 ### 11.1 一份对照表,方便各位收藏 为了让童鞋们对从老 API 切到 `pathlib` 心里有数,这里把最常见的迁移做成一份表: | 操作 | 老写法(`os` / `os.path` / `open`) | 新写法(`pathlib`) | | --- | --- | --- | | 当前工作目录 | `os.getcwd()` | `Path.cwd()` | | 用户家目录 | `os.path.expanduser('~')` | `Path.home()` | | 路径拼接 | `os.path.join(a, b, c)` | `Path(a) / b / c` | | 文件名 | `os.path.basename(p)` | `Path(p).name` | | 主干(去后缀) | `os.path.splitext(name)[0]` | `Path(p).stem` | | 扩展名 | `os.path.splitext(name)[1]` | `Path(p).suffix` | | 父目录 | `os.path.dirname(p)` | `Path(p).parent` | | 是否存在 | `os.path.exists(p)` | `Path(p).exists()` | | 是不是文件 | `os.path.isfile(p)` | `Path(p).is_file()` | | 是不是目录 | `os.path.isdir(p)` | `Path(p).is_dir()` | | 绝对路径 | `os.path.abspath(p)` | `Path(p).resolve()` | | 相对路径 | `os.path.relpath(p, base)` | `Path(p).relative_to(base)` | | 创建目录 | `os.makedirs(p, exist_ok=True)` | `Path(p).mkdir(parents=True, exist_ok=True)` | | 删除文件 | `os.remove(p)` | `Path(p).unlink()` | | 删除空目录 | `os.rmdir(p)` | `Path(p).rmdir()` | | 列目录 | `os.listdir(p)` | `list(Path(p).iterdir())` | | 通配匹配 | `glob.glob(pattern)` | `Path(...).glob(pattern)` | | 递归通配 | `glob.glob(pattern, recursive=True)` | `Path(...).rglob(pattern)` | | 读文本 | `with open(p) as f: content = f.read()` | `Path(p).read_text(encoding='utf-8')` | | 写文本 | `with open(p, 'w') as f: f.write(s)` | `Path(p).write_text(s, encoding='utf-8')` | | 文件大小 | `os.path.getsize(p)` | `Path(p).stat().st_size` | 各位写代码的时候忘了,回来翻一翻就好。 ### 11.2 常见踩坑 #### 坑一:忘了 `Path` 不会自动创建文件 ```python from pathlib import Path p = Path('/tmp/two_water_not_exist.txt') print(p) print(p.exists()) ``` 输出: ``` /tmp/two_water_not_exist.txt False ``` 是不是发现什么了?光 `Path('xxx')` 不会在磁盘上真的搞出一个文件来,它只是创建一个「路径对象」。这个对象代表的文件可能存在,也可能不存在。要真把文件搞出来,得 `.touch()`、`.write_text()`、`.mkdir()` 这些「真做事」的方法。 #### 坑二:`Path('foo') == Path('./foo')`? ```python from pathlib import Path a = Path('foo') b = Path('./foo') print(a == b) print(a.resolve() == b.resolve()) ``` 输出: ``` False True ``` 注意了,`Path('foo')` 和 `Path('./foo')` 在「字符串层面」是不相等的,但 `.resolve()` 之后就一样了。如果各位要比较两个路径是否「指向同一个东西」,建议先 `.resolve()` 再比,或者用更专业的 `.samefile(other)` 方法(要求两边都真的存在)。 #### 坑三:`.suffix` 对没扩展名的文件返回空字符串 ```python from pathlib import Path p = Path('/tmp/Makefile') print(repr(p.suffix)) print(repr(p.stem)) ``` 输出: ``` '' 'Makefile' ``` 各位写「按后缀过滤」的代码时要小心:`.suffix == ''` 对 `Makefile`、`README` 这种不带扩展名的文件成立,可别误伤。 #### 坑四:`mkdir` 不带 `exist_ok` 会炸 ```python from pathlib import Path p = Path('/tmp/two_water_collide') p.mkdir(exist_ok=True) p.mkdir(exist_ok=True) print('两次都没炸:', p.exists()) ``` 输出: ``` 两次都没炸: True ``` 如果不加 `exist_ok=True`,第二次 `mkdir` 就会抛 `FileExistsError`。写脚本的时候,几乎所有 `mkdir` 都建议加上这个参数。 #### 坑五:`relative_to` 不能跨越根 ```python from pathlib import Path a = Path('/tmp/foo') b = Path('/var/log') try: print(a.relative_to(b)) except ValueError as e: print('炸了:', e) ``` 输出: ``` 炸了: '/tmp/foo' is not in the subpath of '/var/log' OR one path is relative and the other is absolute. ``` 这种情况下没办法用 `relative_to`,只能借助 `os.path.relpath`,或者 Python 3.12+ 的 `walk_up=True` 参数: ```python from pathlib import Path a = Path('/tmp/foo') b = Path('/var/log') print(a.relative_to(b, walk_up=True)) ``` 输出大概是: ``` ../../tmp/foo ``` `walk_up=True` 是 3.12 才加的,会允许结果里包含 `..`。 ## 十二、小实战:递归统计某目录下所有 `.py` 文件的总行数 讲了这么多,我们用一个小函数把上面学的东西串起来。需求是这样的: > 给定一个目录,递归统计这个目录下所有 `.py` 文件的总行数。 老写法可能会这样: ```python import os def count_py_lines_old(root): total = 0 for dirpath, dirnames, filenames in os.walk(root): for name in filenames: if name.endswith('.py'): full = os.path.join(dirpath, name) with open(full, 'r', encoding='utf-8') as f: total += sum(1 for _ in f) return total ``` `os.walk` 嵌两层 `for`,再 `os.path.join`,再 `with open`,活活七八行才把核心逻辑写完。 那 `pathlib` 写起来是啥样? ```python from pathlib import Path def count_py_lines(root: Path) -> int: """递归统计 root 目录下所有 .py 文件的总行数。""" total = 0 for py in root.rglob('*.py'): if not py.is_file(): continue text = py.read_text(encoding='utf-8', errors='ignore') total += text.count('\n') + (0 if text.endswith('\n') or not text else 1) return total if __name__ == '__main__': n = count_py_lines(Path.cwd()) print(f'当前目录下 .py 总行数:{n}') ``` 我们走读一下: 1. `root.rglob('*.py')` 递归找出所有 `.py` 文件,省掉 `os.walk` 那一坨 2. `py.is_file()` 直接挂在对象上,看着就舒服 3. `py.read_text(...)` 一行把文件全读出来,不用 `with open` 4. 数行数用 `text.count('\n')`,再补一下「最后一行没换行符」的情况 整个核心循环就 5 行,跟老写法对比,是不是清爽了一大截? 那么再问一个问题:如果某些 `.py` 文件不是 UTF-8 编码会怎样?`read_text` 会抛 `UnicodeDecodeError`。我们这里加了 `errors='ignore'`,遇到不能解码的字节就跳过,保证统计不中断。这种小技巧在写工具脚本的时候特别有用。 各位善于思考的童鞋可以再优化一下: - 排除 `.venv`、`__pycache__` 这种目录 - 区分「空行」和「非空行」分别统计 - 加个 `--ext` 参数,让它支持任意扩展名 这就当作课后作业,自己玩起来吧。 ## 十三、彩蛋:和老 API 互通 各位看到这里可能会担心:项目里有些「祖传代码」用的是字符串路径,或者用了某个第三方库,它的接口要求传字符串而不是 `Path`,怎么办? 其实根本不用担心。`Path` 跟字符串之间互相转换非常顺。 ### 13.1 `Path` → 字符串:直接 `str()` ```python from pathlib import Path p = Path('/tmp/foo.txt') s = str(p) print(s) print(type(s)) ``` 输出: ``` /tmp/foo.txt ``` `str(p)` 就把 `Path` 对象转回了纯字符串。任何接受字符串路径的老 API,都可以这么传。 ### 13.2 字符串 → `Path`:`Path(s)` 反过来更简单,前面我们已经用过无数次了: ```python from pathlib import Path s = '/tmp/foo.txt' p = Path(s) print(p) ``` ### 13.3 `os.PathLike`:标准库的兼容协议 从 Python 3.6 开始,`os.PathLike` 这个协议让标准库里几乎所有接受路径的函数(包括 `open`、`os.listdir`、`shutil.copy` 等等)都能直接吃 `Path` 对象。也就是说: ```python from pathlib import Path p = Path('/tmp/two_water_compat.txt') p.write_text('hi', encoding='utf-8') with open(p, 'r', encoding='utf-8') as f: print(f.read()) ``` 输出: ``` hi ``` 是不是发现什么了?`open` 第一个参数我们直接传了 `Path` 对象,没有先 `str()`,照样能跑。这就是 `os.PathLike` 协议的功劳。 所以各位放心用 `pathlib`,跟标准库里的「老朋友」基本无缝兼容。 ## 十四、小结 讲了这么多,最后总结一下 `pathlib` 的核心要点: 第一,路径在 `pathlib` 里不再是字符串,而是「对象」。它自己知道自己叫什么、在哪、是不是文件、能不能读。这种「对象自治」的设计,让代码读起来更接近自然语言。 第二,`/` 操作符把路径拼接做成了一种「视觉上和路径一致」的语法。`Path('/tmp') / 'foo' / 'bar.txt'` 这种写法,比 `os.path.join` 那一长串好太多了。 第三,`pathlib` 把跨多个老模块的功能(`os.path`、`os`、`open`、`shutil` 的一部分)整合到了一个对象上。`p.exists()`、`p.read_text()`、`p.mkdir(parents=True, exist_ok=True)`,全部从 `p` 这个对象出发,不用再到处 import。 各位童鞋以后写新代码,就别再 `os.path.join` 一条道走到黑啦,直接 `from pathlib import Path` 是真香。