跳到内容

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+0000U+10FFFF;其中 U+D800U+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+0800U+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 grapheme cluster 规则的库。

UTF-16 里的 char 陷阱

Java 的 char 是一个 16-bit UTF-16 编码单元,不等同于完整 Unicode 码点。😀U+1F600,在 UTF-16 中需要一对代理项,因此:

java
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"

# 默认 strict:遇到非法 UTF-8 就抛出 UnicodeDecodeError
# payload.decode("utf-8")

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

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,比较 strictreplaceignore;说明哪一种会静默丢数据。
  4. 找出 é 的 NFC 与 NFD 表示,并比较各自的码点和 UTF-8 byte。
  5. 为一个“最多 20 个字符”的输入框写出更精确的产品约束。

文字最终仍要落回 byte

Unicode 解决了编号和编码规则,却没有规定多 byte 数值在内存或协议中的排列。下一篇进入字节序与位掩码,学习怎样显式拆装整数,以及怎样避开 C 移位运算的边界。

Built with VitePress | Software Systems Atlas