一句话定义:Unicode 规范化(Unicode Normalization)是把字符串按统一规则重写成标准形式的操作,因为人眼看到的同一个字符,在 Unicode 里往往有多种合法的编码方式。
同一个“é”,两套编码
Unicode 用码位(code point)给字符编号。问题在于,“é”至少有两种写法:
- 预组合字符(precomposed character):单码位 U+00E9,一个码位就是“e 带尖音符”;
- 组合字符(combining character):U+0065(普通的 e)加上 U+0301(组合尖音符),两个码位叠起来显示成同一个样子。
渲染出来完全一样,但码位序列不同。而绝大多数编程语言的字符串相等判断,比较的就是码位序列,所以 "caf\u00e9" == "cafe\u0301" 会返回 False,长度一个 4 一个 5。
打个比方:同一个地址,有人写“北京市朝阳区建国路 1 号”,有人写“北京 朝阳区 建国路 1 号”。快递系统如果逐字节比对,就会认为这是两个地址。
规范化就是规定一套标准写法:不管原始写法如何,统一转成同一串码位,然后再比较。
四种形式,别混用
| 形式 | 做法 | “é”的结果 | 常见用途 |
|---|---|---|---|
| NFC | 先分解,再按标准重新组合 | U+00E9 | 存储、比较、展示的通用默认 |
| NFD | 彻底分解 | e + U+0301 | 逐字处理、语音分析、文本编辑 |
| NFKC | 兼容分解后再组合 | é 不变;但 fi→fi、①→1、A→A、㎏→kg | 搜索、去重、标识匹配 |
| NFKD | 兼容分解 | 同上,但不重新组合 | 索引、模糊匹配 |
关键区别在“兼容”(compatibility):全角字母、连字、上标数字、单位符号这类“排版变体”,在 NFC 和 NFD 下保持原样,只有 NFKC 和 NFKD 会把它们折叠成普通字符。这种折叠是有损的:对搜索是好事,对要原样展示或精确处理的场景则可能出错。
和相邻概念的区别
规范化不等于大小写折叠(case folding):它完全不管大小写。也不等于排序规则(collation):后者决定“谁排在谁前面”,普通话拼音排序、德语词典序都是另一套机制。更不等于同形字(homoglyph)防护:西里尔字母“а”和拉丁字母“a”是两个不同的字符,规范化不会把它们合并。
实际会踩的坑
1. 去重失效。用户表里出现两条“José”,一条 NFC 一条 NFD,唯一索引没拦住,因为数据库比对的是码位。
2. 搜索匹配不上。索引里存的是 NFC,用户从别处粘贴过来的查询词是 NFD,检索结果为空;数据量小的时候很难发现。
3. 登录异常。注册时按键入原样存了 NFD,登录时输入法给的是 NFC,密码或用户名对不上。
4. 截断切坏字符。NFD 下一个带音标的字母占多个码位,按码位截断可能把组合序列劈成两半,输出乱码。
5. 滥用 NFKC。把用户昵称、代码片段、数学符号都拿去做兼容折叠,展示时会变样,甚至改变语义。
还有一个容易忽略的点:规范化不是安全机制。Unicode 标准文件本身提醒过这一点,同形字冒充需要额外的混淆字符检测,规范化解决不了。
从业者可以立即做的三件事
- 定一条规范并写进代码:存储统一用 NFC,所有比较前对双方做同一个形式的规范化,别一边一种。
- 区分场景:展示和存储走 NFC;搜索、去重、标识符匹配可以考虑 NFKC,但要保留原文以便回显。
- 确认下游:数据库、搜索引擎、对象存储各自是否开启 ICU 规范化、文件名如何处理,各家实现有差异,具体以官方文档为准。
Python 里一行就能验证:unicodedata.normalize("NFC", b) == a。这行代码值得加在任何“用户输入去重”的入口处。
