同一个文件名,两种字节:NFC 与 NFD 归一化引发的比较失败
同一个文件名,两种字节:NFC 与 NFD 归一化引发的比较失败
两个文件在 ls 里看着一模一样,用脚本比对 name1 == name2 却是 False;Git 把一个没动过的文件同时标成 deleted 和 untracked;打包后用 unzip 解出来,某些中文或带重音的文件名变成乱码方块。这三件事经常指向同一个原因:同一个"字符"在 Unicode 里有不止一种合法编码形态,而比较是在字节层面做的。
等价但不等长
字母 é 有两种写法:
- 预组合形式:U+00E9(LATIN SMALL LETTER E WITH ACUTE),一个码位。
- 分解形式:U+0065 + U+0301(
e后接 COMBINING ACUTE ACCENT),两个码位。
两者渲染结果相同,Unicode 称之为"规范等价"。但字符串比较、哈希、路径查找、数据库唯一索引,看的都是码位序列,所以它们不相等。这不是 bug,是规范本来的设计——分解形式让变音符号可以自由叠加。
不同系统的默认存档形态不一样:
| 系统 / 场景 | 通常存什么 |
|---|---|
| macOS 文件系统(HFS+ 及 APFS 的兼容层) | NFD(分解) |
| Linux ext4、多数数据库 | 原样字节,不归一化 |
| Windows NTFS | UTF-16 原样,不归一化 |
Java Normalizer、Python unicodedata |
需显式调用 |
于是"在 macOS 上建目录、在 Linux 上读"的流水线最容易踩到:文件名看起来一致,find、rsync、git 的字符串匹配全部失配。
一条命令判断当前形态
python3 - <<'PY'
import sys, unicodedata
s = sys.argv[1] if len(sys.argv) > 1 else "café"
print("len =", len(s))
print("codepoints =", [hex(ord(c)) for c in s])
print("is NFC =", unicodedata.is_normalized("NFC", s))
print("is NFD =", unicodedata.is_normalized("NFD", s))
print("nfc == s:", unicodedata.normalize("NFC", s) == s)
print("nfd == s:", unicodedata.normalize("NFD", s) == s)
PY
预组合形式输出 len = 4、码位末位 0xE9、is NFC = True;分解形式输出 len = 5、码位末两位 0x65 0x301、is NFD = True。把这两串字节 xxd 出来,肉眼就能看出差 1 个字节。
排查顺序建议是先看长度再看码位:长度不同基本可以确定是组合形式差异;长度相同但比较失败,才去怀疑大小写、全角半角、零宽字符或尾部空格。
修法:在边界上统一,而不是在比较处补救
正确的做法是选定一种形态作为"系统内部标准",在所有外部输入进入时归一化一次。绝大多数场景选 NFC:它更短、在多数终端和 Web 表单里是默认,也是 W3C 推荐的交换形式。
import unicodedata
def canon(name: str) -> str:
return unicodedata.normalize("NFC", name)
必须覆盖的边界包括:上传文件名、解压出的 zip 条目名、对象存储的 key、从 CSV 读进来的列名、用户输入的搜索词。归一化只做一次,之后全链路都用归一化后的值,别在每个比较点各写一遍。
三个容易忽略的坑
一,别对二进制做归一化。 normalize() 只对文本有意义,对图片、压缩包、加密数据调用会把字节改掉。判断依据是数据来源:文本协议解析出来的字段可以归一化,文件内容流不行。
二,归一化不等于大小写折叠,而且顺序有讲究。 想同时忽略大小写和组合形式差异,正确顺序是先 casefold() 再 normalize(),或者反过来都可以,但必须全链路统一,不能一处 lower() 一处 casefold()。土耳其语的 İ 在 lower() 下变成 i̇(带点的 i),casefold() 得到 i 加组合点,两者又不一样。
三,归一化会改变长度,破坏按字节偏移的逻辑。 如果某段代码先用 byte_offset 定位再切片,归一化后偏移全错。这类代码要么在归一化之前完成所有偏移计算,要么改成基于码位而非字节。
验证是否真的修好
改完之后不要只看"这次跑通了"。构造两组名字做断言,才说明归一化真的生效:
python3 -c "
import unicodedata as u
a = 'caf\u00e9'; b = 'cafe\u0301'
assert a != b
assert u.normalize('NFC', a) == u.normalize('NFC', b)
assert u.normalize('NFD', a) == u.normalize('NFD', b)
print('ok')
"
a != b 这一行别删——它证明测试数据确实覆盖了差异;如果这条断言失败,说明源文件在保存时已经被编辑器归一化了,测试就失去了意义。
- 点赞
- 收藏
- 关注作者
评论(0)