从 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+4E2DE4 B8 AD4E2D00004E2D
😀U+1F600F0 9F 98 80D83D DE000001F600

由此可以看到,同一个 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 码点序列
  ↓ 字体选择、字形塑造与排版
屏幕上的字形

这里有两个常见误区。

  1. “一个字符占一个字节”是错误的。 在 UTF-8 中,一个 Unicode 标量值可能占 1~4 个字节;一个用户眼中的字符又可能由多个码点组成,所以它占用的字节数可能更多。
  2. 原始字节不会自行说明自己是哪种文本编码。 例如,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 是嵌套对象,citypostal_code 都属于 address

如果只把值直接拼接起来:张三18Tokyo100-0001,编码为二进制后只有 0-1 串,接收方无法仅凭这段内容可靠地判断字段边界、类型和层级。除非双方另有固定长度、分隔符或外部 schema 等约定,否则这些值并没有形成可解析的结构。

为什么不能直接复制内存

在程序中,结构化数据通常表现为结构体、对象、数组或映射。它们在内存中当然也以比特和字节存在,但内存布局只是当前进程中的实现细节,并不是稳定的数据交换格式。

直接把一块内存 memcpy 到文件或网络中,通常会遇到这些问题:

  • 编译器和 ABI 可能采用不同的字段排列、对齐和填充规则;
  • 整数宽度、浮点表示和字节序可能不同;
  • 指针和引用只在当前进程的地址空间中有意义;
  • 字符串、容器和对象图通常并不连续存放在一块内存中;
  • 数据结构升级后,旧布局与新布局可能不兼容。

因此,持久化或传输结构化数据时,需要定义一种稳定、明确,并尽可能跨平台、跨语言的外部表示。把内存中的逻辑数据转换为这种表示的过程叫作序列化,反向恢复的过程叫作反序列化

常见的序列化格式

序列化格式大致可以分为两类。

  1. 文本序列化格式,例如 JSON。JSON 负责表示字段、值、数组和对象等结构;字符编码再负责把 JSON 文本转换为字节。在开放网络中交换的 JSON 文本应使用 UTF-8。因此,完整链路是:

    结构化数据 → JSON 文本 → UTF-8 字节
    
  2. 二进制序列化格式,例如 Protocol Buffers。直接按照二进制规则把结构化数据编码为字节:

    结构化数据 → 二进制格式的编码规则 → 字节
    

最终可以把两类问题分开理解:

  • 字符编码回答“文本如何变成字节”;
  • 序列化回答“结构化数据如何变成稳定、可解析的表示”。

JSON 会同时经过这两层:先用 JSON 表达数据结构,再用 UTF-8 编码 JSON 文本。Protocol Buffers 则直接规定结构化数据的字节表示,不需要先把整个结构转换成文本,但其中的字符串字段仍然需要遵守相应格式规定的字符编码。

Reference

评论