Unicode 规范化
将同一字符的不同表示统一的处理。有 NFC、NFD、NFKC、NFKD 四种形式。
Unicode 规范化是将表示同一字符的不同码位序列转换为统一形式的处理。在 Unicode 中,同一字符有时可以用多种方式表示。例如,日语字符 "が" 既可以用单个码位 U+304C (预组合形式) 表示,也可以用 "か" (U+304B) + 组合浊点 (U+3099) 两个码位表示。虽然外观相同,但字节序列不同,因此不进行规范化就无法正确比较字符串。
规范化有四种形式。NFC (规范分解后规范合成) 先分解再重新合成,是 Web 标准推荐的形式。NFD (规范分解) 只进行规范分解,将组合字符分离。NFKC (兼容分解后规范合成) 在兼容分解后进行合成,包含将全角英数字转换为半角等兼容性转换。NFKD (兼容分解) 只进行兼容分解。
在实际应用中,规范化形式的选择对系统行为有重大影响。在搜索引擎和数据库中,如果用户输入与存储数据的规范化形式不一致,外观相同的字符串也无法在搜索中匹配。例如,一个用户以 NFC 输入的 "が" 和另一个用户以 NFD 输入的 "が",在不进行规范化的情况下会被视为不同的字符串。JavaScript 提供 String.prototype.normalize() 方法进行任意形式的转换,Python 提供 unicodedata.normalize() 函数。
关于 macOS 的文件名,不同世代的处理方式并不相同。旧版 HFS+ 会把文件名规范化成接近 NFD 的独有形式之后再保存到磁盘,因此把在 macOS 上创建的文件名带到 Windows 或 Linux 上时,会出现比较不一致的问题。而现行的 APFS 则按给定的形态原样保存,在比对时使用规范化后形态的哈希,从而把组合形与分解形视为同一个。Apple 的公开资料中说明,macOS High Sierra 之后的 APFS 不区分规范化 (同一个名字的组合形与分解形无法在一个目录中并存)。因此 "macOS 以 NFD 保存" 这种说法是旧 HFS+ 时代的东西。不过,过去以分解形创建的文件名会保持那个形态留下来,所以在用 zip 或 tar 交给其他环境的那一刻,不一致就会浮现出来。Git 中有用于抵消 macOS 一侧分解的 core.precomposeUnicode 设置,在与 Linux 或 Windows 共享仓库时使用。
一个常见的误解是认为规范化只是日语或韩语等特定语言的问题,但实际上拉丁字母的重音符号 (é、ñ 等) 和阿拉伯文的组合形式等许多语言都需要规范化。此外,NFKC 规范化会将全角英数字转换为半角,可能会意外改变字符的外观,在搜索用途中很方便,但在显示用途中需要注意。带圈数字 "①" 变成 "1"、"㈱" 被展开为 "(株)",也都是 NFKC 所为。
另一个陷阱是,不只 NFKC 和 NFKD,就连 NFC 和 NFD 也有字符被替换掉的情形。CJK 兼容汉字定义了到标准等价的统一汉字的对应关系,因此一施加 NFC 就会变成另一个码位。按 Unicode 的数据来数,兼容汉字 1,014 字中有 1,002 字属于替换对象。例如人名中使用的 U+FA10 的 "塚",在 NFC 之后会变成 U+585A 的 "塚"。兼容汉字本来是为了与其他字符编码之间往返转换而收录的字符,并不是指定字形的手段。想保住特定字形时,不要依赖规范化,而要使用变体选择符。
从字符计数的角度来看,规范化形式不同会导致同一字符的码位数不同,从而使计数结果产生差异。在 NFC 中 "が" 是 1 个码位,但在 NFD 中变为 2 个码位。为了获得准确的字符数,建议在计数前将文本转换为统一的规范化形式。不过 NFC 只能把 "Unicode 中备有预组合字符的那些组合" 归并起来。例如给 "カ" (U+30AB) 加上组合半浊点 (U+309A) 得到的字符并不存在预组合形式,所以即使施加 NFC 也仍然是 2 个码位。也就是说,光靠规范化并不能保证 "看起来 1 个字 = 1 个码位",若想按外观的单位来数,就要一并使用书写素簇单位的分割 (在 JavaScript 中是 Intl.Segmenter)。