从 Unicode 到序列化
在讨论文本的存储与传输时,Unicode 和 UTF-8 经常被混为一谈。两者关注的并不是同一个问题:Unicode 定义字符及其数字编号,UTF-8 则规定这些编号如何表示为可存储、可传输的数据。
Unicode 定义字符及其码点
通常可以把 Unicode 理解为一套统一的字符集,但更准确地说,Unicode 是一项字符编码标准。它不仅收录字符,还为字符分配码点,并定义字符属性、规范化规则等内容。
- 字符:这里指 Unicode 所定义的抽象字符,例如汉字“中”或拉丁字母“A”。
- 码点(code point):Unicode 代码空间中的一个整数位置,通常写成
U+XXXX。例如,“中”的码点是U+4E2D。
码点只是数字标识,并不直接规定它在文件或网络中占用哪些字节。换句话说,Unicode 主要解决的是“文本中的字符如何获得统一编号”的问题。
还要注意,“字符”在不同语境中可能指不同东西。一个用户眼中的字符,也就是一个字素簇,可能由多个 Unicode 码点组成。某些带组合符号的字母和 emoji 就是如此。因此,一个字符 = 一个码点 也并非始终成立。
码点、码元、字节
UTF-8、UTF-16 和 UTF-32 是 Unicode 的三种编码形式。它们并不是直接把“字符”塞进固定数量的字节,而是把 Unicode 标量值映射为码元序列。
**码元(code unit)**是某种编码形式处理文本时采用的最小定长单位。码元没有统一的位数,其宽度由编码形式决定:UTF-8 的码元宽度是 8 位,UTF-16 是 16 位,UTF-32 是 32 位。一个 Unicode 标量值可能需要一个码元,也可能需要多个码元表示。
例如,“中”的码点是 U+4E2D,emoji“😀”的码点是 U+1F600。它们在三种编码形式中的码元序列如下:
| 字符 | 码点 | UTF-8 码元序列 | UTF-16 码元序列 | UTF-32 码元序列 |
|---|---|---|---|---|
| 中 | U+4E2D | E4 B8 AD | 4E2D | 00004E2D |
| 😀 | U+1F600 | F0 9F 98 80 | D83D DE00 | 0001F600 |
由此可以看到,同一个 Unicode 标量值在不同编码形式中会得到不同的码元序列:
-
UTF-8 使用 8 位码元,一个码元就是一个字节;
U+4E2D位于:U+0800 ~ U+FFFF,所以需要 3 个 UTF-8 字节。3 字节 UTF-8 的模板是:
1110xxxx 10xxxxxx 10xxxxxx,16 个 个x,正好可以放入U+4E2D的 16 个有效二进制位。从右往左填进去:0100 111000 101101
分别填入模板:
1110 0100 10 111000 10 101101得到:
11100100 10111000 10101101转换成十六进制:E4 B8 AD
-
UTF-16 使用 16 位码元,一个 Unicode 标量值可能对应一个或两个码元;
-
UTF-32 使用 32 位码元,一个 Unicode 标量值对应一个码元,因此UTF-32 码元序列与码点相同
以“😀”为例,它在 UTF-8 中占 4 个码元,也就是 4 个字节;在 UTF-16 中占 2 个码元,共 32 位;在 UTF-32 中只占 1 个码元,同样是 32 位。这里“码元数量”和“字节数量”显然不是一回事,除非讨论的是 UTF-8。
如果只讨论最常见的 UTF-8,可以把过程简化为:
抽象字符
↓ Unicode 为字符分配码点
Unicode 码点
↓ UTF-8 编码
字节序列
把 UTF-16 和 UTF-32 也纳入模型时,还要区分“码元”和“字节”:
抽象字符
↓
Unicode 码点
↓ UTF-8 / UTF-16 / UTF-32
码元序列
↓ 按相应的编码方案转换
字节序列
UTF-16 和 UTF-32 的码元宽度大于一个字节,因此写入文件或网络时还涉及字节序。UTF-8 的码元本身就是单个字节,没有这个问题。
读取文本时,过程反过来:
字节序列
↓ 按指定的字符编码解码
Unicode 码点序列
↓ 字体选择、字形塑造与排版
屏幕上的字形
这里有两个常见误区。
- “一个字符占一个字节”是错误的。 在 UTF-8 中,一个 Unicode 标量值可能占 1~4 个字节;一个用户眼中的字符又可能由多个码点组成,所以它占用的字节数可能更多。
- 原始字节不会自行说明自己是哪种文本编码。 例如,
E4 B8 AD按 UTF-8 解码是“中”,但换一种编码解释,结果可能不同,甚至可能是非法序列。编码信息必须来自协议约定、文件元数据、BOM 或双方事先约定。所谓“纯文本文件”并不是没有编码,只是编码往往被环境默认了。
从文本编码到结构化数据
UTF-8 解决的是“文本如何表示为字节”。但应用程序需要存储或传输的通常不只是孤立文本,而是带有字段、类型和层级关系的结构化数据。要把这类数据变成字节,还需要序列化格式。
什么是结构化数据
结构化数据通常包含以下信息:
- 字段及其名称;
- 字段的数据类型;
- 对象之间的层级或引用关系;
- 各字段在业务中的语义。
例如:
User
├── name: string = "张三"
├── age: uint32 = 18
└── address
├── city: string = "Tokyo"
└── postal_code: string = "100-0001"
这里有意义的不只是 "张三"、18 和 "Tokyo" 这些值,还包括它们之间的关系:name 是字符串,age 是整数,address 是嵌套对象,city 和 postal_code 都属于 address。
如果只把值直接拼接起来:张三18Tokyo100-0001,编码为二进制后只有 0-1 串,接收方无法仅凭这段内容可靠地判断字段边界、类型和层级。除非双方另有固定长度、分隔符或外部 schema 等约定,否则这些值并没有形成可解析的结构。
为什么不能直接复制内存
在程序中,结构化数据通常表现为结构体、对象、数组或映射。它们在内存中当然也以比特和字节存在,但内存布局只是当前进程中的实现细节,并不是稳定的数据交换格式。
直接把一块内存 memcpy 到文件或网络中,通常会遇到这些问题:
- 编译器和 ABI 可能采用不同的字段排列、对齐和填充规则;
- 整数宽度、浮点表示和字节序可能不同;
- 指针和引用只在当前进程的地址空间中有意义;
- 字符串、容器和对象图通常并不连续存放在一块内存中;
- 数据结构升级后,旧布局与新布局可能不兼容。
因此,持久化或传输结构化数据时,需要定义一种稳定、明确,并尽可能跨平台、跨语言的外部表示。把内存中的逻辑数据转换为这种表示的过程叫作序列化,反向恢复的过程叫作反序列化。
常见的序列化格式
序列化格式大致可以分为两类。
-
文本序列化格式,例如 JSON。JSON 负责表示字段、值、数组和对象等结构;字符编码再负责把 JSON 文本转换为字节。在开放网络中交换的 JSON 文本应使用 UTF-8。因此,完整链路是:
结构化数据 → JSON 文本 → UTF-8 字节 -
二进制序列化格式,例如 Protocol Buffers。直接按照二进制规则把结构化数据编码为字节:
结构化数据 → 二进制格式的编码规则 → 字节
最终可以把两类问题分开理解:
- 字符编码回答“文本如何变成字节”;
- 序列化回答“结构化数据如何变成稳定、可解析的表示”。
JSON 会同时经过这两层:先用 JSON 表达数据结构,再用 UTF-8 编码 JSON 文本。Protocol Buffers 则直接规定结构化数据的字节表示,不需要先把整个结构转换成文本,但其中的字符串字段仍然需要遵守相应格式规定的字符编码。
Reference
- The Unicode Standard, Version 17.0,Chapter 2,重点参见 2.5 Encoding Forms、2.6 Encoding Schemes 和 2.7 Unicode Strings。
- The Unicode Standard, Version 17.0,Chapter 3,重点参见 3.9 Unicode Encoding Forms。其中 D76~D78 分别定义了 Unicode 标量值、码元和码元序列。
- RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format,Section 8.1,说明开放系统之间交换的 JSON 文本必须使用 UTF-8。
- Protocol Buffers Documentation: Encoding,介绍 Protocol Buffers 的 wire format 及字段在二进制数据中的编码方式。
- BSON Specification, Version 1.1,定义 BSON 文档、元素、字段名和各数据类型的二进制表示。
评论