算法与数据结构 / regex · Unicode 字符模型与一个自研引擎 / 大小写:映射与折叠是两套规则 待审核 19 / 36
字符的真面目 · 大小写

大小写:映射与折叠是两套规则

aA 看着是最简单的一对,底下藏着三处不平凡:转换会改长度,ß 的大写是两个字母 SS;转换依赖语言,土耳其语的 I 转小写不是 i;而且「给人看的转换」与「给机器比较用的折叠」是两套不同的规则,正则的 i 标志走的是后者。本页把这几层拆开,它与 归一化 同属「比较前把同义写法拍平」这一族。

1 · 映射与折叠

Unicode 把「变换大小写」分成两件事。case mappingtoLowerCase / toUpperCase 与词首大写形)是给人看的转换,结果要合书写习惯,所以依赖 locale,也可能一对多。case folding 是给机器比较用的规范形态,目标只是「忽略大小写后能相等」,不依赖 locale、结果稳定,也不保证可读——开尔文号 U+212A 会折成普通的 k。折叠又分 simple(一对一)与 full(允许一对多,ß 折成 ss)。

表 1-1 · 六个字符在 JS 里的实测大小写结果,长度、字形与上下文都可能变。
字符 toLowerCase toUpperCase 说明
ß U+00DF ß SS 德语 eszett,大写变两个字母
U+FB01 FI 连字,大写拆成两个字母
U+FB06 ST 同上,st 连字
İ U+0130 İ 带点大写 I,小写得 i 加组合点两个码点
Σ U+03A3 σς Σ 希腊 sigma,词尾用 ς,转换看上下文
Dž U+01C5 dž DŽ 本身是词首大写形,三态互不相同

sigma 那行值得多看一眼:JS 的 toLowerCase 真的分上下文,'ΣΑΣ'.toLowerCase() 给出 σας——同一个 Σ,词首折成 σ、词尾折成 ς

图 1-1 · 一个字符的大小写转换结果,同时给出码元、码点与字素簇三层长度,长度变化的行标红。可换输入或点预设。

2 · locale 依赖

大小写映射不是全球一张表。最经典的是土耳其语与阿塞拜疆语:它们在 i / I 之外另有无点的 ı 与带点的 İ,于是同一个 I,默认规则转小写得 i,土耳其规则得 ı。这不是冷僻边角:'FILE'.toLocaleLowerCase('tr') 得到的是 fıle,拿它去比 file 直接不等。

图 2-1 · 同一个输入在默认规则与指定 locale 下的四种转换结果,与默认不同的行标红。可换字母或切换 locale。

警示 ·「转小写再比较」是常见的大小写无关比较手法,但不要用带 locale 的 toLocaleLowerCase() 做这件事:土耳其语环境下它会把 FILE 变成 fıle,比较随即失败。要的是与语言无关的规范形态时,走折叠那条路。

3 · 正则 i 的折叠

开了 i 且开了 u(或 v)的正则用折叠判等,比「两边各 toLowerCase 再比」更严。差异不难找:ſ(长 s,U+017F)的 toLowerCase 就是它自己,所以 's'.toLowerCase() === 'ſ'.toLowerCase() 为假,而 /s/iu.test('ſ') 为真;µ(微符号 U+00B5)与希腊 μ、词尾 ςσ 都是同样的情形。反过来也有:/ß/iu.test('ss') 是假,因为 JS 正则用的是 simple folding(一对一),ß 折成 ss 属于 full folding,于是 /straße/iu.test('STRASSE') 返回 false

这个前提不能省。ECMA-262 的 Canonicalize 分两条路:带 u / v 标志时查 CaseFolding.txt 的 simple 折叠,不带时改用 toUppercase 那套默认大小写转换。差别当场可见——/s/iu.test('ſ') 为真,写成 /s/i.test('ſ') 就变成假。上面几例一律带标志,正是这个缘故。

图 3-1 · 十组字符对,逐对同时跑正则 i 判等与 toLowerCase 判等,两者结论不同的行标出分歧。

4 · 大小写属性

UCD 里与大小写有关的二元属性 JS 都能直接查:\p{Cased} 标记「有大小写之分」,\p{Case_Ignorable} 是判定时应当跳过的附属符号,Changes_When_* 一族标记某种转换会不会改变这个字符。上面那些反直觉的字符在这组属性上也各有各的位置:ßChanges_When_Uppercased 为真,而数字与汉字整族 Cased 为假。

图 4-1 · 单个字符在七个大小写属性上的实测命中。可换输入或点预设。

注 · 给人看用映射,注意 locale、也要接受长度可能变;给机器做大小写无关的比较、去重与查找用折叠,正则的 i 就是它。两者都属「同义写法拍平」,与归一化互补:严谨的做法是先归一再折叠,顺序反过来会漏掉预组合与分解之别。

5 · 参考文献

  1. Unicode. The Unicode Standard, Chapter 5: Implementation Guidelines. 5.18 节讲大小写映射与折叠的分工,以及为何折叠不保证可读。unicode.org
  2. Unicode. CaseFolding.txt. 折叠表本身:条目分 C(两种折叠共用)、F(full 专有)、S(simple 与 full 不同处)、T(土耳其语变体),simple 折叠取 C 加 S,full 折叠取 C 加 F。unicode.org
  3. Unicode. SpecialCasing.txt. 一对多映射与依赖 locale 的规则出处,ßSS、词尾 sigma、土耳其语 I 都在其中。unicode.org
  4. Ecma International. ECMA-262: Canonicalize. 正则 i 标志的判等定义:带 u / v 时走 simple case folding,不带时走 toUppercase 默认转换。tc39.es
  5. MDN. String.prototype.toLocaleLowerCase(). 与 toLowerCase() 的差别,以及何时该显式传 locale。developer.mozilla.org