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 扩展字素簇规则,并固定库所用的 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 运行:
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 序列只有配合编码规则才成为文本。读取文件和网络消息时,应在边界处明确编码,并为非法输入选择策略:
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);真正的风险更多来自错误边界、非法输入和不一致的规范化策略。
动手检查文本的真实结构
- 分别计算
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 移位运算的边界。