中文文件名排序跟拼音对不上:os.listdir 返回的是文件系统顺序,三个中文名按字节排结果正好相反

有一批中文命名的素材文件,放在同一个目录里,想让它们按拼音排好给前端列表用。我很自然地写了一句 sorted(os.listdir(path)),跑完发现顺序还是乱的------排了,但排得跟拼音没关系。
这篇就把这件事拆开:os.listdir 到底返回什么,sorted() 到底在比什么,拼音序又是另一套什么规则,为什么同一批名字按字节排出来跟拼音序正好反过来。
1 先看清:os.listdir 返回的不是排序结果
1.1 一个最小复现
三行代码就能看出问题。在临时目录里造三个中文名文件:
bash
cd "$(mktemp -d /tmp/cn-sort.XXXXXX)" && touch 李四.txt 张三.txt 阿明.txt
python3 -c "import os; print(os.listdir('.'))"
本机实测(macOS 14.5 arm64,APFS)输出:
text
['李四.txt', '张三.txt', '阿明.txt']
这正好是我 touch 的顺序,不是任何排序。原因很直接:os.listdir 是把目录的目录项(dirent)按文件系统给出的顺序读出来,它做的是「枚举」,不是「排序」。Python 文档从来没承诺过这个顺序,同一个目录在不同文件系统、不同时刻都会不一样。
os.listdir 返回的是目录项枚举顺序,随创建顺序变化,不是任何排序结果
这里有个容易忽略的点:连续跑几次顺序通常稳定,看起来像"有规律"。那只是当前文件系统的目录项排列稳定,换个目录、换个创建次序就变了,不能当契约用。
1.2 sorted() 比的是码点,不是拼音
那再加一层 sorted() 总该对了吧。也不对。Python 对字符串默认比较的是 Unicode 码点,而 UTF-8 是保序编码------码点大小顺序和 UTF-8 字节序列的大小顺序一致。所以对中文来说,sorted() 等价于「按 UTF-8 字节排」。
汉字在 Unicode 码表里的位置来自部首、笔画等历史整理顺序,跟读音没关系。所以 sorted(['张三','李四','阿明']) 得到的是字表顺序,不是拼音顺序。
sorted() 走 Unicode 码点序,对中文等于按字表位置排,与拼音序无关
很多人看到 sorted() 结果"看着像有点规律",就默认它等于拼音序,这是最容易踩的一处误判。
2 两套完全不同的规则
2.1 按字节排:只看编码,不看读音
「按字节排」是把字符串编码成 UTF-8,再逐字节比较。张 是 e5 bc a0,李 是 e6 9d 8e,首字节 e5 < e6,所以「张」排在「李」前面。这个规则绝对确定、跨平台一致,也正因为确定,它跟中文语义没有半点关系。
bytes 比较、sorted(os.listdir())、MySQL 的 utf8mb4_bin 排序,走的都是这一套。
2.2 按拼音排:需要额外的排序规则表
拼音序属于「语言相关排序」(collation):先知道这个汉字念什么,再按字母序比,后面还常会挂上声调、笔画做次级规则。这套规则不在 Python 内置里,得靠外部数据或系统 locale 提供:
pypinyin:把汉字转成拼音字符串再排,最可控,零系统依赖locale.strcoll+ 中文 locale:依赖系统LC_COLLATE的排序表,各平台行为不一致- ICU / PyICU:跨平台的完整 collation 实现,重量级
先记住一句话:字节序是「编码顺序」,拼音序是「读音顺序」,两套规则互不来往。
3 造一个反转:三个中文名,字节序和拼音序正好相反
光说没用,直接在机器上把反转造出来。选三个名字:李四 、张三 、阿明。
- 拼音序:a(阿) < li(李) < zhang(张)
- 需要验证字节序是不是整条反过来
下面这段脚本把三个名字、UTF-8 十六进制、两种排序结果全打印出来,收尾自动清掉临时目录,不留残留:
python
# cn_sort_demo.py
import os, tempfile, shutil
names = ["李四.txt", "张三.txt", "阿明.txt"]
d = tempfile.mkdtemp(prefix="cn-sort-")
try:
for n in names: # 故意按这个顺序创建
open(os.path.join(d, n), "w").close()
print("os.listdir 原序 :", os.listdir(d))
print("\n[UTF-8 字节十六进制]")
for n in names:
print(f" {n:10} -> {n.encode('utf-8').hex(' ')}")
by_bytes = sorted(names, key=lambda s: s.encode("utf-8"))
print("\n按字节序 :", by_bytes)
from pypinyin import lazy_pinyin
print("按拼音序 :", sorted(names, key=lazy_pinyin))
print("\n原序 == 字节序 ?", os.listdir(d) == by_bytes)
finally:
shutil.rmtree(d)
真实输出(本机实测,macOS 14.5 arm64,APFS,Python 3.14.3):
text
os.listdir 原序 : ['李四.txt', '张三.txt', '阿明.txt']
[UTF-8 字节十六进制]
李四.txt -> e6 9d 8e e5 9b 9b 2e 74 78 74
张三.txt -> e5 bc a0 e4 b8 89 2e 74 78 74
阿明.txt -> e9 98 bf e6 98 8e 2e 74 78 74
按字节序 : ['张三.txt', '李四.txt', '阿明.txt']
按拼音序 : ['阿明.txt', '李四.txt', '张三.txt']
原序 == 字节序 ? False
把关键点摊开:
| 名字 | 首字 | 首字 UTF-8 | 拼音 | 字节序位置 | 拼音序位置 |
|---|---|---|---|---|---|
| 张三.txt | 张 | e5 bc a0 |
zhang | 1 | 3 |
| 李四.txt | 李 | e6 9d 8e |
li | 2 | 2 |
| 阿明.txt | 阿 | e9 98 bf |
a | 3 | 1 |
字节序是 张三 → 李四 → 阿明,拼音序是 阿明 → 李四 → 张三。首尾整条调了个头,完全相反。
为什么会这样:张 的码点 U+5F20、李 的 U+674E、阿 的 U+963F,在码表里依次递增,所以字节序是 张 < 李 < 阿;而拼音是 a < li < zhang。码表位置由部首笔画整理出来,读音只有巧合才会对齐,整条反过来的例子一抓一大把。
顺带一句话提醒:os.listdir 原序这回正好等于字节序,纯属 APFS 上创建顺序跟码点顺序撞一起了,别拿这个当规律。上面测到的是 False 还是 True,取决于创建顺序,不能写进逻辑。
三个名字的字节序与拼音序对比,首尾整条反转
4 第三个坑:locale.strcoll 在 macOS 上的实际表现
想省掉 pypinyin 依赖,很多人会去用 locale.strcoll。它就是坑最多的那一块。
4.1 默认 locale 是 C,等于又退回字节序
不设 LC_COLLATE 时,locale.getlocale(locale.LC_COLLATE) 本机实测返回:
text
(None, None)
这时生效的是 C locale,strcoll 做的是逐字节比较,中文排出来跟 sorted() 一模一样,白折腾。
4.2 setlocale 会「假装成功」
按官方文档的写法先设 locale:
python
import locale, functools
locale.setlocale(locale.LC_COLLATE, "zh_CN.UTF-8")
print(sorted(names, key=functools.cmp_to_key(locale.strcoll)))
本机实测(macOS 14.5 arm64)这段不抛异常,setlocale 返回 'zh_CN.UTF-8',看着像成功。但排序结果还是:
text
['张三.txt', '李四.txt', '阿明.txt']
换 zh_CN.GBK、zh_CN.GB18030、zh_CN.GB2312、zh_TW.UTF-8、zh_HK.UTF-8,本机实测结果全一样,还是字节序。locale -a 里这些名字都在,setlocale 也接受,但 macOS 的 libc 并没有为它们提供拼音 collation 规则,等于「接受了参数,什么也没做」。
这比直接报错更难查:在你的 Linux 上跑得好好的,挪到 Mac 上不报错、不崩溃,只是结果悄悄错了。
4.3 另一个坑:strcoll 不能直接当 key
locale.strcoll 是两参数函数 strcoll(a, b),直接写成 key=locale.strcoll 会抛:
text
TypeError: strcoll expected 2 arguments, got 1
必须包一层 functools.cmp_to_key(locale.strcoll)。这一点在文档里不太显眼,sorted 只吃单参数 key。
4.4 一条命令自查本机
想确认自己机器是什么行为,一行够:
bash
python3 -c "import locale,functools;n=['李四.txt','张三.txt','阿明.txt'];locale.setlocale(locale.LC_COLLATE,'zh_CN.UTF-8');print('ok');print(sorted(n,key=functools.cmp_to_key(locale.strcoll)))"
打印 ok 之后再看到 张三, 李四, 阿明 这个字节序结果,就是本机没有拼音 collation;如果打印出 阿明, 李四, 张三,才说明 locale 规则真的生效了。先跑这条再选方案,比事后排查省事。
5 稳定复用的写法
要中文按拼音排,固定用 pypinyin,别赌系统 locale:
python
import os
from pypinyin import lazy_pinyin
def list_cn_sorted(path, suffix=".txt"):
names = [n for n in os.listdir(path) if n.endswith(suffix)]
return sorted(names, key=lazy_pinyin)
print(list_cn_sorted("/tmp/your-dir"))
本机实测(macOS 14.5 arm64,APFS)对上面那三个文件输出:
text
['阿明.txt', '李四.txt', '张三.txt']
lazy_pinyin 返回拼音列表,列表之间逐元素比较,首字同音时自动比下一个字,最后才退回 Python 默认的码点比较,顺序是全确定的。要再加笔画或声调做次级规则,就在 key 里拼上第二段。
6 让异地同事一起验:把脚本临时挂到公网
这类「你这儿反转、我这儿没反转」的问题,光看截图很难说清。最省事的办法是让同事在自己机器上跑同一段脚本,把输出贴回来对照。
但对比的重点不是 os.listdir 原序,而是「字节序 vs 拼音序」这两列。文件系统不同,目录项顺序本来就不同------同一批名字在 ext4 和 APFS 上,os.listdir 原序几乎不会一致,比原序只会越比越乱。
本机起个临时服务:
bash
mkdir -p /tmp/cn-sort-share && cd /tmp/cn-sort-share
python3 -m http.server 8765
另一个终端:
bash
cpolar http 8765
cpolar 会给一个公网地址,同事浏览器打开就能拿到脚本和对照表。口径提前说清,免得到时候以为地址丢了是自己操作错了:
- 免费版给的随机公网地址不固定,24 小时内不保证还是同一个域名,隧道重启会变;想给同一批人一个长期不变的入口,得用 cpolar 后台里固定二级子域名的档位。
- 要直连固定 TCP 端口(比如 SSH 过去跑),免费版给的是随机端口,固定端口同样需要后台里对应的档位。
临时对一次结果,免费版完全够;对完把 cpolar 的隧道停掉,python3 -m http.server 一起 Ctrl+C 退掉。
7 总结
这件事已经交代清楚了:os.listdir 是枚举不是排序,sorted() 是码点序不是拼音序,再加上真正的拼音序,一共是几套互不相同的东西。用「李四 / 张三 / 阿明」三个名字,字节序排成 张 → 李 → 阿、拼音序排成 阿 → 李 → 张,首尾整条反转,UTF-8 十六进制和两种输出都在上面贴了实测原文。
关键三点:
- 不要相信
os.listdir的顺序,它随文件系统和创建顺序变,业务逻辑不能依赖它。 sorted()对中文就是按 UTF-8 字节排,可复现、跨平台一致,但跟读音无关。- 要拼音序就上
pypinyin;locale.strcoll在 macOS 上会「接受参数但不生效」,还要求cmp_to_key包一层,能不用就别用。
同一段脚本在两台机器上跑出不同结果,是这类问题最常见的表象。把「字节序 / 拼音序」的边界划清楚,再让同事各自跑一遍,排序规则一次说清,后面做文件列表、素材归档、导出报表都不用再靠试。