同一个文件名,两种字节:NFC 与 NFD 归一化引发的比较失败

举报
yd_228692151 发表于 2026/11/12 06:49:28 2026/11/12
【摘要】 同一个文件名,两种字节:NFC 与 NFD 归一化引发的比较失败两个文件在 ls 里看着一模一样,用脚本比对 name1 == name2 却是 False;Git 把一个没动过的文件同时标成 deleted 和 untracked;打包后用 unzip 解出来,某些中文或带重音的文件名变成乱码方块。这三件事经常指向同一个原因:同一个"字符"在 Unicode 里有不止一种合法编码形态,而...

同一个文件名,两种字节: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 这一行别删——它证明测试数据确实覆盖了差异;如果这条断言失败,说明源文件在保存时已经被编辑器归一化了,测试就失去了意义。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。