跳到内容

第 3 卷 / 第 1 章 / 第 2 课

1.2 字符、Unicode 与 UTF ​

上一篇把数据还原成了比特、字节与进制。现在给这些 byte 加上文字含义,并把字符、码点、编码单元与字形分开。

屏幕上的一个符号,内存里未必只有一个单位 ​

名字、界面标签和日志看起来都是“文字”,但程序处理它们时涉及的单位并不只有一种:

  • **字符(character)**是语义层面的抽象概念,具体边界取决于上下文;
  • **码点(code point)**是 Unicode 码空间中的一个编号;有些已分配给字符,有些尚未分配。例如“中”位于 U+4E2D;
  • **编码单元(code unit)**是某种编码格式的基本单位:UTF-8 用 8 bit,UTF-16 用 16 bit;
  • byte 是实际存储和传输时看到的单位;
  • **字形(glyph)**是字体最终画出的形状;
  • **用户感知字符(grapheme cluster)**更接近光标移动一次所经过的内容,可能由多个码点组成。

因此,“字符串长度是多少”没有脱离语境的唯一答案。它可能问 byte 数、编码单元数、码点数,也可能问用户看到的字符数。

ASCII 解决的是早期英文信息交换 ​

ASCII 定义了 128 个编号,只需 7 bit,包括英文字母、数字、标点和控制字符。A 是十进制 65,即十六进制 0x41。

早期系统常在 8-bit 存储单元中保存 ASCII;额外一位有时用于奇偶校验,有时固定为零,也可能被后来的扩展字符集使用。不能笼统地说“ASCII 的第八位就是校验位”。

ASCII 的局限不只是放不下中文。不同地区各自扩展单字节编码后,同一个 byte 可能代表不同字符。若发送端和接收端没有约定同一种编码,所谓“乱码”其实是用错误规则解释了同一串 byte。

Unicode 先统一编号,再选择编码格式 ​

Unicode 的码点写作 U+ 加十六进制数。码点空间从 U+0000 到 U+10FFFF;其中 U+D800 到 U+DFFF 保留给 UTF-16 代理项,不是 Unicode 标量值。

码点编号不是磁盘格式。UTF-8、UTF-16 和 UTF-32 是把 Unicode 标量值编码为编码单元序列的不同方案:

编码格式编码单元一个标量值通常占用特点
UTF-88 bit1–4 byteASCII 兼容,Web 与 Unix 环境常用
UTF-1616 bit1 或 2 个编码单元基本多文种平面通常 1 个,其他平面使用代理对
UTF-3232 bit1 个编码单元定位直接,但空间开销通常较大

“Unicode 文件”这个说法不够精确。工程接口应写清 UTF-8、UTF-16LE 等具体编码格式;UTF-16/32 的 byte 顺序还需要约定或由 byte order mark 辅助识别。

把“中”编码成 byte ​

“中”对人只有一个字形,程序要把码点 U+4E2D 写入文件时,却必须选择具体编码。这里按 UTF-8 规则把这个码点拆成三个 byte。

“中”的码点是 U+4E2D。它落在 U+0800 到 U+FFFF,UTF-8 使用三个 byte,模板是:

text
1110xxxx 10xxxxxx 10xxxxxx

把 0x4E2D 的有效位依次填入,得到:

text
11100100 10111000 10101101
   E4       B8       AD

UTF-8 的 ASCII 范围保持单 byte 且数值相同。多 byte 序列的首 byte 表示长度,后续 byte 都以二进制 10 开头,这使解码器能识别边界并拒绝许多非法组合。合法 UTF-8 还必须排除过长编码、代理项和超出 U+10FFFF 的值。

让运行时展示每一层 ​

Python 3 的 str 表示 Unicode 文本,bytes 表示原始 byte。编码把 str 变成 bytes,解码执行反方向转换。

python
import unicodedata

text = "A中😀"
encoded = text.encode("utf-8")

print([f"U+{ord(char):04X}" for char in text])
print(encoded.hex(" "))
print(len(text), len(encoded))
print(encoded.decode("utf-8"))

decomposed = "e\u0301"
composed = "é"
print(decomposed == composed)
print(unicodedata.normalize("NFC", decomposed) == composed)

输出为:

text
['U+0041', 'U+4E2D', 'U+1F600']
41 e4 b8 ad f0 9f 98 80
3 8
A中😀
False
True

这里 Python 的 len(text) 统计码点,不统计用户感知字符。e 加组合重音符与预组字符 é 看起来可能相同,底层序列却不同;NFC 规范化后,这个例子才相等。更复杂的 emoji、旗帜和家庭组合仍可能包含多个码点,分割通常采用 Unicode 扩展字素簇规则,并固定库所用的 Unicode 版本。字素簇是默认的用户感知字符近似,具体光标与显示行为还可能由语言环境和应用调整。

UTF-16 里的 char 陷阱 ​

Java 的 char 是一个 16-bit UTF-16 编码单元,不等同于完整 Unicode 码点。😀 是 U+1F600,在 UTF-16 中需要一对代理项,因此,把下面的完整程序保存为 TextUnits.java,用 javac -encoding UTF-8 TextUnits.java 编译,再用 java TextUnits 运行:

java
public class TextUnits {
    public static void main(String[] args) {
        String text = "A😀";
        System.out.println(text.length());
        System.out.println(text.codePointCount(0, text.length()));
        System.out.printf("U+%X%n", text.codePointAt(1));
    }
}

输出分别是 3、2 和 U+1F600。遍历完整码点可用 text.codePoints();即使如此,一个码点仍不保证等于一个用户感知字符。

解码失败应该是可见的 ​

byte 序列只有配合编码规则才成为文本。读取文件和网络消息时,应在边界处明确编码,并为非法输入选择策略:

python
payload = b"ok\xff"

try:
    payload.decode("utf-8")
except UnicodeDecodeError:
    print("invalid UTF-8")

print(payload.decode("utf-8", errors="replace"))  # ok�

第一行输出 invalid UTF-8,第二行是 ok�。Python str 和 Java String 也能在某些操作中容纳孤立代理项;拥有字符串对象不等于数据一定可编码为合法 UTF-8,边界校验仍然必要。

replace 适合尽量展示受损日志,却会丢失原始信息;身份标识、协议字段和签名输入通常应选择 strict 并拒绝非法数据。不要悄悄用平台默认编码兜底,否则错误会在另一台机器或下一次部署时出现。

文本处理的工程边界 ​

  • 按 byte 截断 UTF-8 可能切进一个多 byte 序列;限制数据库字段或消息大小时,要说明限制的是 byte 还是字符。
  • 大小写转换不总是一进一出,也不一定与语言环境无关;用户名比较需要单独定义规范。
  • 规范化会改变码点序列。先确定协议或产品要求,再选择 NFC、NFD 等形式,不能把所有文本无条件“清洗”。
  • BOM 不是所有 UTF 格式都必需。UTF-8 的编码单元本来就是一个 byte,无需另选字节序,BOM 常用于标记编码,但某些工具会把它当内容。
  • 编码转换通常是线性扫描,时间复杂度为 O(n);真正的风险更多来自错误边界、非法输入和不一致的规范化策略。

动手检查文本的真实结构 ​

  1. 分别计算 hello、中文、😀 的 UTF-8 byte 数和码点数。
  2. 解释为什么 Java 中 "😀".length() 是 2,而 Python 3 中 len("😀") 通常是 1。
  3. 构造一段非法 UTF-8,比较 strict、replace 与 ignore;说明哪一种会静默丢数据。
  4. 找出 é 的 NFC 与 NFD 表示,并比较各自的码点和 UTF-8 byte。
  5. 为一个“最多 20 个字符”的输入框写出更精确的产品约束。

文字最终仍要落回 byte ​

Unicode 定义了文本编码及其序列化形式;业务协议中的多 byte 整数字段仍须单独约定排列方式。下一篇进入字节序与位掩码,学习怎样显式拆装整数,以及怎样避开 C 移位运算的边界。

参考资料 ​

Built with VitePress | Software Systems Atlas