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-8 | 8 bit | 1–4 byte | ASCII 兼容,Web 与 Unix 环境常用 |
| UTF-16 | 16 bit | 1 或 2 个编码单元 | 基本多文种平面通常 1 个,其他平面使用代理对 |
| UTF-32 | 32 bit | 1 个编码单元 | 定位直接,但空间开销通常较大 |
“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,模板是:
1110xxxx 10xxxxxx 10xxxxxx把 0x4E2D 的有效位依次填入,得到:
11100100 10111000 10101101
E4 B8 ADUTF-8 的 ASCII 范围保持单 byte 且数值相同。多 byte 序列的首 byte 表示长度,后续 byte 都以二进制 10 开头,这使解码器能识别边界并拒绝许多非法组合。合法 UTF-8 还必须排除过长编码、代理项和超出 U+10FFFF 的值。
让运行时展示每一层
Python 3 的 str 表示 Unicode 文本,bytes 表示原始 byte。编码把 str 变成 bytes,解码执行反方向转换。
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)输出为:
['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 中需要一对代理项,因此:
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 序列只有配合编码规则才成为文本。读取文件和网络消息时,应在边界处明确编码,并为非法输入选择策略:
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);真正的风险更多来自错误边界、非法输入和不一致的规范化策略。
动手检查文本的真实结构
- 分别计算
hello、中文、😀的 UTF-8 byte 数和码点数。 - 解释为什么 Java 中
"😀".length()是 2,而 Python 3 中len("😀")通常是 1。 - 构造一段非法 UTF-8,比较
strict、replace与ignore;说明哪一种会静默丢数据。 - 找出
é的 NFC 与 NFD 表示,并比较各自的码点和 UTF-8 byte。 - 为一个“最多 20 个字符”的输入框写出更精确的产品约束。
文字最终仍要落回 byte
Unicode 解决了编号和编码规则,却没有规定多 byte 数值在内存或协议中的排列。下一篇进入字节序与位掩码,学习怎样显式拆装整数,以及怎样避开 C 移位运算的边界。