Unicode 内部机制:字形群、代码点和规范化形式
如果你曾经纳闷为什么你的代码显示字符串 "é" 的长度是 2 而不是 1,或者为什么一个简单的表情符号就能让你的数据库崩溃,那么你已经遇到了 Unicode 隐藏的复杂性。在现代全球化的数字世界中,理解文本已不再像将一个字节映射到一个字符那么简单。
在本指南中,我们将探索 Unicode 的基本概念,从原始的代码点到高级的字形群,并解释为什么文本规范化是字符串比较中默默无闻的英雄。
1. 构建块:代码点和代码单元
从本质上讲,Unicode 是人类使用过的每一个字符的巨型列表,从古埃及象形文字到最新的表情符号。
代码点(“ID”)
代码点 (Code Point) 是分配给字符的唯一编号。它写作 U+ 后跟一个十六进制数。例如:
U+0041是 'A'U+1F600是 '😀'
代码单元(“字节”)
代码单元 (Code Unit) 是用于表示代码点的物理存储单位。大小取决于编码方式(UTF-8 使用 8 位单元,UTF-16 使用 16 位单元)。
UTF-16 代理对
UTF-16 是 JavaScript、Java 和 C# 使用的内部编码。因为它使用 16 位代码单元,所以它只能直接表示 $2^{16} = 65,536$ 个字符。为了表示超出此范围的字符(如大多数表情符号),UTF-16 使用 代理对 (Surrogate Pair) —— 两个 16 位单元组合在一起表示一个代码点。
- 示例: '😀' 表情符号是一个代码点,但它占用 两个 UTF-16 单元。这就是为什么在 JavaScript 中
"😀".length返回 2。
2. 字形群:用户看到的
程序员看到的是代码点,而用户看到的是 字形 (Graphemes)。
什么是字形群?
字形群 (Grapheme Cluster) 是显示为单个视觉单元的一个或多个代码点的序列。
- 示例: 字符 'é' 可以存储为:
- 单个代码点:
U+00E9(LATIN SMALL LETTER E WITH ACUTE) - 两个代码点的组合:
U+0065(字母 'e') +U+0301(组合用锐音符) 对于用户来说,它们看起来一模一样。对于计算机来说,它们是完全不同的字符串。
- 单个代码点:
3. 规范化的力量:NFC 和 NFD
为了使字符串比较可靠,我们必须“规范化”我们的文本,使视觉上相同的字符具有相同的二进制表示。
规范化形式 D (NFD) - 规范分解
NFD 将字符分解为其组成部分。
- 'é' 变为 'e' + '´'(两个代码点)。
规范化形式 C (NFC) - 规范组合
NFC 尽可能将组成部分组合成单个字符。
- 'e' + '´' 变为 'é'(一个代码点)。
- 大多数 Web 应用程序将 NFC 作为标准。
兼容性规范化 (NFKC, NFKD)
这些形式更进一步,对“视觉相似”但在含义上不完全相同的字符进行规范化。例如,它会将用于表示“平方”的符号 '²' 转换为数字 '2'。这对于搜索索引很有用,但可能会丢失重要的格式信息。
4. 开发者最佳实践
- 始终规范化用户输入: 在比较字符串(如用户名或密码)时,在存储或检查之前始终将它们规范化为 NFC。
- 使用感知字形的库: 如果你需要正确计算字符串的长度(按照用户看到的),不要使用
.length。在现代浏览器中请使用库或Intl.SegmenterAPI。 - 警惕 UTF-16 长度: 记住许多字符是代理对。在 Python 或 Rust 中,字符串默认是 UTF-8,但在 JS/C#/Java 中,你必须小心索引操作。
结论
Unicode 是现代工程的杰作,旨在解决遗留字符编码的混乱局面。通过理解代码点和字形之间的区别,并掌握 NFC 和 NFD 等规范化形式,你可以构建出能为每位用户(无论其语言或使用的设备如何)正确处理文本的应用程序。