大小写转换

将字母的大写 (uppercase) 和小写 (lowercase) 相互转换的处理。不同语言的转换规则各异,某些情况下转换还会导致字符数发生变化。

大小写转换 (case conversion) 是文本处理中最常见的操作之一。英语中"A"转为"a" (小写化) 或"hello"转为"HELLO" (大写化),26 个字母一一对应。然而放眼全球语言,大小写转换的复杂程度远超想象。

最著名的例外是德语的艾斯策特"ß"。小写的"ß"大写化后变为"SS" (2 个字符),也就是说大写化会使字符数增加。大写的"ẞ" (U+1E9E) 是 2008 年在 Unicode 5.1 中加入的,德语正字法在 2017 年予以容许,2024 年的规则修订把大写写法定为原则 ("SS"也继续被认可)。另一方面,程序默认的大写化至今仍返回"SS",因此显示上的正字法与处理结果会出现偏离。土耳其语中"i"的大写是"İ" (带上点),"I"的小写是"ı" (无点),与英语的转换规则不同。

在编程中,经常需要不区分大小写的比较 (case-insensitive comparison)。电子邮件地址的本地部分区分大小写,但域名部分不区分。URL 的协议名 (http/HTTP) 和主机名不区分大小写,但路径部分区分。要正确处理这些规格上的差异,就需要明确在哪一部分执行不区分大小写的比较。

JavaScript 的 toLowerCase() 和 toUpperCase() 支持 Unicode,但依赖区域设置的转换需要使用 toLocaleLowerCase()。在土耳其语区域设置下执行 'I'.toLocaleLowerCase('tr') 会返回"ı",而英语区域设置下返回"i"。忽略区域设置的转换是国际化处理中 bug 的温床。反过来,也有不该混入区域设置的场合。在土耳其语区域设置下 'file'.toLocaleUpperCase('tr') 会返回"FİLE",因此把扩展名或命令名大写化后再比较的处理,会随终端的语言设置而变得不再一致。标识符和 URL 这类面向机器的字符串,用不依赖用户区域设置的 toUpperCase() / toLowerCase(),或者明确指定 'en-US' 来统一,才是安全的。

把忽略大小写的比较实现为"小写化之后用 ==="同样危险。德语的"Straße"与"STRASSE",即使小写化后也是"straße"和"strasse",并不一致。若需要这种同一视,就用排序规则器,如 new Intl.Collator('de', { sensitivity: 'base' }).compare('Straße', 'STRASSE'),把返回值 0 判定为"相等" (实际会返回 0)。判断标准很简单:如果希望对人而言当作同一个词来处理,就用排序规则器;如果希望按字节单位、码位单位严格一致,就选择不做转换的比较。

作为命名规范的大小写用法也很重要。camelCase (驼峰命名)、PascalCase (帕斯卡命名)、snake_case (蛇形命名)、kebab-case (短横线命名)、SCREAMING_SNAKE_CASE (常量) 等,编程中大小写的模式承载着含义。这些转换不是单纯的大写化、小写化,而是需要在识别出单词边界之后再进行转换。

从字符计数的角度看,需要注意大小写转换会使字符数改变的情形。除了前述德语"ß"转为"SS",还有希腊语的"ς" (词尾 sigma) 转为"Σ"再转为"σ" (词中 sigma) 这样、小写的形态会因位置而改变的语言。增加并不只发生在大写化的时候。把土耳其语的大写"İ"小写化时,在某些环境下会分解为"i"和组合用的点 (U+0307) 这 2 个字符。在有字符数限制的字段上,遵守"不按输入时点的字符数、而按转换后的字符数来判定"这一顺序才是可靠的。此外,转换并不一定能往返。把"Straße"大写化再小写化会得到"strasse",回不到原来的拼写。想保留原本的写法时,就不要把转换结果覆盖到保存目标上,而应设计成另外持有一份用于比对的键。

分享这篇文章