在词元化(tokenization)时还可以考虑另一个选项:以单个字符为单位。 但问题随之而来:我们该如何表示跨越不同语言和书写系统的字符呢? Unicode标准正是为此而生,它是一种用于表示世界上任何语言(包括已消亡的语言如苏美尔楔形文字,以及人造语言如克林贡语)所使用的所有字符和文字的编码方法。
我们先简要回顾一下 Unicode 中一个仅面向英语的子集(在 Unicode 标准中技术上称为“基本拉丁字母”(Basic Latin),通常被称为 ASCII)。 自 20 世纪 60 年代起,用于书写英语的拉丁字母(比如本句中使用的这些字符)采用一种名为 ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)的编码方案进行表示。 ASCII 使用单个字节(byte)表示每个字符。 一个字节最多可表示 256 个不同的字符,但 ASCII 仅使用了其中的 127 个;其字节的最高位始终设为 0。 (实际上,它只用到了其中 95 个可打印字符,其余是为一种早已淘汰的设备电传打字机(teletype)预留的控制字符。) 下图展示了一些 ASCII 字符及其十六进制与十进制编码:

图 2.4 部分英文字符对应的 ASCII 编码,同时以十六进制和十进制形式列出。
然而,ASCII 显然远远不够,因为世界上各种书写系统包含的字符远不止这些! 即使对于同样使用拉丁字母的文字,其字符数量也远超 ASCII 的 95 个可打印字符。 例如,下面这句西班牙语(意为“先生,桑丘答道”)就包含两个非 ASCII 字符:ñ 和 ó:
(2.10) Se ñor- respondió Sancho-
更不用说,世界上大量语言根本不使用拉丁字母! 例如,天城文(Devanagari)被用于 120 种语言(包括印地语、马拉地语、尼泊尔语、信德语和梵语)。 以下是《世界人权宣言》印地语文本中的一个天城文示例:

中文在 Unicode 中收录了约 10 万个汉字(包括中文、日文、韩文和越南文所使用的重叠与非重叠变体,字符合称 CJKV)。
截至 Unicode 16.0 版本,总共支持超过 15 万个字符和 168 种不同的文字系统。 尽管全球仍有不少文字尚未被纳入 Unicode,但目前已涵盖的范围极其广泛,既有现代语言使用的文字(如中文、阿拉伯文、印地文、切罗基文、埃塞俄比亚文、高棉文、N’Ko 文、土耳其文、西班牙文等),也有古代语言的文字(如楔形文字、乌加里特文、埃及象形文字、巴列维文),还包括数学符号、表情符号(emoji)、货币符号等等。
2.3.1 码位(Code Points)
Unicode 是如何工作的? 它为这 15 万个字符中的每一个都分配了一个唯一的标识符,称为码位(code point)。
码位是对字符的一种抽象表示,每个码位由一个数字标识,传统上以十六进制书写,范围从 0 到 0x10FFFF(即十进制的 1,114,111)。
拥有超过一百万个码位意味着未来仍有充足空间容纳新字符。
按照惯例,这些码位用前缀 “U+” 表示(意为“以下是一个 Unicode 码位的十六进制表示”)。例如,字符 a 的码位是 U+0061,等同于 0x0061。
(注意:Unicode 在设计时就考虑了与 ASCII 的向后兼容性,因此前 127 个码位——包括 a 的码位——与 ASCII 完全一致。)
以下是一些码位示例(部分附有描述,但并非全部):
U+0061 a LATIN SMALL LETTER A (拉丁小写字母 a)
U+0062 b LATIN SMALL LETTER B (拉丁小写字母 b)
U+0063 c LATIN SMALL LETTER C (拉丁小写字母 c)
U+00F9 ù LATIN SMALL LETTER U WITH GRAVE (带重音符的拉丁小写 u)
U+00FA ú LATIN SMALL LETTER U WITH ACUTE (带锐音符的拉丁小写 u)
U+00FB û LATIN SMALL LETTER U WITH CIRCUMFLEX(带扬抑符的拉丁小写 u)
U+00FC ü LATIN SMALL LETTER U WITH DIAERESIS (带分音符的拉丁小写 u)
U+8FDB 进
U+8FDC 远
U+8FDD 违
U+8FDE 连
U+1F600 😀 GRINNING FACE (咧嘴笑脸)
U+1F00E 🀎 MAHJONG TILE EIGHT OF CHARACTERS (麻将牌“八筒”)
需要注意的是,码位并不指定字形(glyph),即字符的视觉呈现形式。
字形存储在字体(fonts)中。
码位 U+0061 是对字符 a 的抽象表示,而其视觉形式可以有无数种:例如 Times Roman 字体中的 a、Courier 字体中的 a,或粗体(a)、斜体(a)等不同样式。
但无论外观如何变化,它们都对应同一个码位 U+0061。
2.3.2 UTF-8 编码
虽然码位(即唯一标识符)是字符在 Unicode 中的抽象表示,但我们并不会直接把这个 ID 存入文本文件中。
实际上,每当需要在字符串中表示一个字符时,我们使用的是该字符的一种编码(encoding)。 尽管存在多种编码方式,但目前最广泛使用的无疑是 UTF-8(例如,几乎整个互联网都采用 UTF-8 编码)。
我们来谈谈编码。
单词 hello 在 Unicode 中由以下 5 个码位组成:
U+0068 U+0065 U+006C U+006C U+006F
我们可以设想一种非常简单的编码方法:直接把每个码位的数值写入文件。 由于 Unicode 支持超过一百万个字符,16 位(2 字节)不够用,因此可能需要使用 4 字节(32 位)来容纳表示 110 万多个字符所需的 21 位。 (理论上 3 字节也够,但以 3 的倍数处理字节在工程上很不方便。)
按照这种 4 字节方案,hello 将被编码为如下字节序列:
00 00 00 68 00 00 00 65 00 00 00 6C 00 00 00 6C 00 00 00 6F
然而,我们并不使用这种编码(技术上称为 UTF-32),因为它会使文件体积膨胀到 ASCII 的 4 倍,不仅浪费空间,还充斥着大量零字节。 此外,这些零字节还会引发另一个问题:在许多基于 ASCII 的旧系统中,全零字节(0x00)被用作字符串结束标记,因此在文本中出现零字节会破坏向后兼容性。
取而代之的是,目前最主流的编码标准是 UTF-8(Unicode Transformation Format 8),它高效地表示字符(平均使用较少的字节),方式是有些字符使用的字节少,有些字符使用的字节多一些。因此 UTF-8 一种变长编码(variable-length encoding)。
对于前 127 个码位(即 ASCII 字符集),UTF-8 使用单字节进行编码。因此,hello 的 UTF-8 编码就是:
68 65 6C 6C 6F
这带来一个极大的便利:所有 ASCII 编码的文件同时也是合法的 UTF-8 文件!
但 UTF-8 是变长编码,这意味着码位 ≥128 的字符会被编码为 2、3 或 4 个字节的序列。 这些字节的值都在 128 到 255 之间,因此不会与 ASCII 字符混淆;并且每个字节的前几位会明确指示这是一个 2 字节、3 字节还是 4 字节的编码单元。

图 2.5 Unicode 码位到 UTF-8 变长编码的映射规则。对于“From–To”范围内的任一码位,其二进制值(第 2 列)会被打包成 1、2、3 或 4 个字节。图改编自《Unicode 16.0 核心规范》第 3 章 表 3-6。
图 2.5 展示了这一映射过程。
例如,字符 ñ(码位 U+00F1,二进制为 00000000 11110001,其中蓝色部分代表 yyyyy,红色部分代表 xxxxxx)根据规则被编码为两个字节:11000011 10110001,即十六进制 0xC3B1。
根据这些规则,前 127 个字符(ASCII)占用 1 字节;欧洲、中东和非洲大多数文字字符占用 2 字节;中文、日文、韩文(CJK)的大部分字符占用 3 字节;较生僻的 CJKV 字符、表情符号(emoji)及部分特殊符号则占用 4 字节。
UTF-8 具有多项优势。 相对高效,对常见字符使用更少字节;无零字节(除非显式表示空字符 U+0000);完全兼容 ASCII;自同步(self-synchronizing):即使文件部分损坏,只需向前或向后最多扫描 3 个字节,就能重新定位到下一个或上一个字符的起始位置。
Unicode 与 Python
从 Python 3 开始,所有 Python 字符串在内部均以 Unicode 形式存储,即每个字符串都是一个 Unicode 码位的序列。
因此,字符串操作函数和正则表达式都天然作用于码位。
例如,len() 函数返回的是字符串中字符的数量(即码位数量),而非字节数。
然而,在读写文件时,这些码位必须通过某种编码方式(如 UTF-8)进行编码(写入时)或解码(读取时)。 也就是说,每个文本文件都有其编码格式。 如果不是 UTF-8,那可能是较旧的编码方式,如 ASCII 或 Latin-1(ISO 8859-1)。 不存在“没有编码”的文本文件。 在 Python 中,打开文件进行读写时,需显式指定编码方式。