变音符号
添加在字符上方或下方的辅助符号。表示发音差异,如重音符号和元音变音 (umlaut)。
变音符号 (发音区别符号) 是添加在字符上方、下方或旁边的辅助符号的总称。常见示例包括法语重音符号 (é, è, ê)、德语变音符号 (ä, ö, ü)、西班牙语波浪号 (ñ) 和捷克语抑扬符 (č, š, ž) 等,广泛用于世界各地的语言,尤其是基于拉丁字母的语言。这些符号并非单纯的装饰,而是区分发音和含义的必要元素。例如在法语中,ou (或者) 和 où (哪里) 仅因重音符号的有无而含义不同。
Unicode 提供了两种方式来表示带变音符号的字符。一种是"预组合字符" (NFC: Normalization Form Composed),将 é 作为单个码位 U+00E9 处理。另一种是"基础字符 + 组合字符" (NFD: Normalization Form Decomposed),将 e (U+0065) 与锐音符 (U+0301) 组合表示。这种双重表示是 Unicode 的设计特征,旨在保持与现有字符编码的兼容性,同时灵活表示任何语言的字符。
在实际开发中,这种双重表示会在字符串比较和排序中引发严重问题。即使两个"é"看起来完全相同,NFC 和 NFD 的字节序列也不同,简单的字节比较不会判定为一致。在数据库检索、文件名匹配、密码验证等需要判断字符串同一性的场景中,必须应用 Unicode 规范化来统一表示形式。
关于文件名,需要准确掌握各文件系统的处理方式。把名字统一成分解形的是 macOS 的旧文件系统 HFS+,而现行的 APFS 会原样保存给定的形式。实际在 APFS 上用合成形的名字 (含 U+00E9) 创建文件,保存下来的就是合成形,即使用分解形的名字去打开,也会解析到同一个文件。也就是说 APFS 的行为是"不强制规范化,但两种形式都视为同一个"。另一方面,Windows 和 Linux 上常用的文件系统不会规范化名字,比较也严格按码位进行,因此分解形与合成形的名字是两个不同的文件。这种不对称,会在把 macOS 上创建的文件带到其他环境时,以"有两个同名文件""仓库里本该存在的文件找不到"的形式浮现出来。Git 中由 core.precomposeUnicode 承担这一层吸收。
一个常见的误解是把变音符号简单地统称为"重音符号"。重音符号只是变音符号的一种,更广的概念还包括软音符 (ç)、反尾形符 (ą)、长音符 (ā) 等。另外,日语的浊点和半浊点在"附加于基础字符的符号"这一点上确实属于同类,但要注意它们在 Unicode 中分成两个系统。单独占据宽度的"゛" (U+309B) 和"゜" (U+309C) 不是组合字符,而被归类为符号,真正参与组合的是 U+3099 和 U+309A。"か" + U+3099 经规范化会合成为"が",但附加外观相似的 U+309B 则不会合成,仍是 2 个字符。在处理浊点的代码里,不确认送来的是哪一个码位,就会残留即使规范化也修不好的不一致。
在编程中,可以使用 JavaScript 的 String.prototype.normalize() 方法或 Python 的 unicodedata.normalize() 函数进行规范化。在 Web 应用程序中接收表单输入时,通常在服务器端应用 NFC 规范化后再存入数据库。
这里要留意的是"只要做了 NFC 规范化就一定会缩成 1 个码位"这种想当然。只有 Unicode 中备有预组合字符的组合才会被合成。e + U+0301 会合成为 U+00E9、z + U+030C 会合成为 U+017E,但 q + U+0301 和 z + U+0304 没有对应的预组合字符,即使施加 NFC 也仍是 2 个码位。所以如果按"已经规范化了,因此码位数 = 可见字符数"的前提来搭逻辑,就会随语言和符号的组合而崩掉。规范化的作用不是缩短长度,而是把同一个字符的写法唯一确定下来,这样理解才准确。
在检索和重复判定中,还会出现与规范化不同的另一类要求,也就是"希望忽略重音符号也能匹配"。这属于用排序规则 (collation) 的强度来调节的领域,而不是规范化,JavaScript 里可以像 new Intl.Collator('fr', { sensitivity: 'base' }) 这样指定。不过在这个强度下,连"有无符号就改变含义"的词也会被视为同一个。实测可知,默认的排序规则会区分 ou 与 où,而 sensitivity: 'base' 下则判定为一致 (比较结果为 0)。检索的入口放宽、广泛捞取候选,而最终的同一性判定 (登录 ID 或唯一约束) 不放宽,这样的分工可以作为判断基准。另外,默认的排序规则本身就会吸收 NFC 与 NFD 的差异,所以以比较为目的时,事先不做规范化也能匹配。
从字符计数角度看,NFD 形式的文本将基础字符和组合字符作为独立码位分别计数,导致计数高于可见字符数。例如"café"在 NFC 中为 4 个字符,但在 NFD 中为 5 个字符 (c, a, f, e, ́)。要获得准确的"可见字符数",需要以书写素簇为单位进行计数,这使得变音符号的处理成为字符计数工具实现中不可回避的课题。