2.3 Unicode
词元化还可以考虑以单个字符为单位。可是,我们究竟该如何表示不同语言和书写系统中的字符?Unicode 标准提供了一种表示文本的方法,能够涵盖世界各语言中任何文字的任何字符(包括苏美尔楔形文字等已经消亡的语言,以及克林贡语等人造语言)。
先简要回顾 Unicode 中一个专用于英语的子集。在 Unicode 中,它的正式名称是“基本拉丁字母”(Basic Latin),通常则称为 ASCII。自 20 世纪 60 年代起,书写英语所用的拉丁字符(例如本句中使用的字符)开始采用一种称为 ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)的编码表示。ASCII 使用单个字节表示每个字符。一个字节可以表示 256 个不同字符,但 ASCII 只使用其中 128 个;ASCII 字节的最高位始终为 0。(实际上,其中只有 95 个用于字符,其余是为电传打字机这种已经淘汰的设备设计的控制码。)下面列出若干 ASCII 字符及其十六进制和十进制表示:
| 字符 | 十六进制 | 十进制 | 字符 | 十六进制 | 十进制 | 字符 | 十六进制 | 十进制 | 字符 | 十六进制 | 十进制 | |
| < | 3C | 60 | @ | 40 | 64 | ... | \ | 5C | 92 | ‘ | 60 | 96 |
| = | 3D | 61 | A | 41 | 65 | ... | ] | 5D | 93 | a | 61 | 97 |
| > | 3E | 62 | B | 42 | 66 | ... | ^ | 5E | 94 | b | 62 | 98 |
| ? | 3F | 63 | C | 43 | 67 | ... | _ | 5F | 95 | c | 63 | 99 |
图 2.4 部分英语字母的 ASCII 编码,同时给出十六进制和十进制形式。
当然,ASCII 远远不够,因为世界各地的书写系统还包含大量其他字符!即使是采用拉丁字母的文字,其字符数量也远不止 ASCII 中的 95 个。例如,下面这句西班牙语(意为“先生,桑丘回答道”)包含两个非 ASCII 字符 ñ 和 ó:
(2.10) —Señor —respondió Sancho—
而且,许多语言根本不以拉丁字母为基础!天城文用于 120 种语言,包括印地语、马拉地语、尼泊尔语、信德语和梵语。原文以《世界人权宣言》的印地语文本为例展示了天城文。
Unicode 中约有 10 万个汉字,其中包括汉语、日语、韩语和越南语所使用的相互重叠或不重叠的字符变体,统称 CJKV。
总的来说,Unicode 16.0 支持超过 15 万个字符和 168 种不同文字。尽管世界上仍有许多文字尚未加入 Unicode,其中现有的种类已经极其丰富:既包括现代语言使用的文字(汉字、阿拉伯字母、天城文、切罗基文、埃塞俄比亚文、高棉文、西非书面字母 N’Ko、土耳其语拉丁字母和西班牙语拉丁字母等),也包括古代语言的文字(楔形文字、乌加里特字母、埃及圣书体和钵罗钵文),此外还有数学符号、表情符号、货币符号等。
2.3.1 码点¶
Unicode 是如何工作的?它为这 15 万多个字符分别分配一个称为码点(code point)的唯一标识符。
码点是字符的抽象表示,每个码点用一个数字表示,传统上采用十六进制记法,取值范围从 0 到 0x10FFFF(十进制为 1,114,111)。一百多万个码点为新增字符留下了充足空间。按照惯例,码点写作带有前缀“U+”的形式;这个前缀只表示后面是一个 Unicode 码点的十六进制表示。因此,字符 a 的码点是 U+0061,与 0x0061 相同。(请注意,Unicode 的设计向后兼容 ASCII,所以前 128 个码点——包括 a 的编码——与 ASCII 完全相同。)下面是一些码点示例,其中部分(并非全部)附有说明:
| U+0061 | a | 拉丁小写字母 A |
| U+0062 | b | 拉丁小写字母 B |
| U+0063 | c | 拉丁小写字母 C |
| U+00F9 | ù | 带抑音符的拉丁小写字母 U |
| U+00FA | ú | 带锐音符的拉丁小写字母 U |
| U+00FB | û | 带扬抑符的拉丁小写字母 U |
| U+00FC | ü | 带分音符的拉丁小写字母 U |
| U+8FDB | 进 | |
| U+8FDC | 远 | |
| U+8FDD | 违 | |
| U+8FDE | 连 | |
| U+1F600 | 😊 | 咧嘴笑脸 |
| U+1F00E | 八萬 | 麻将牌八万 |
请注意,码点并不指定字形(glyph),也就是字符的视觉表示。字形存储在字体中。码点 U+0061 是字母 a 的抽象表示,而它可以有数量不限的视觉形式,例如 Times Roman(a)或 Courier(a)等不同字体,也可以使用粗体(a)或斜体(a)等不同字形样式。不过,所有这些形式都由同一个码点 U+0061 表示。
2.3.2 UTF-8 编码¶
码点(唯一标识符)是字符的抽象 Unicode 表示,但我们并不会直接把这个标识符写入文本文件。
相反,每当需要在文本字符串中表示一个字符时,我们都会写入该字符的一种编码(encoding)。编码方法有许多种,其中称为 UTF-8 的方法使用最广泛,例如几乎整个网络都采用 UTF-8 编码。
下面具体说明编码的含义。单词 hello 的 Unicode 表示由以下 5 个码点组成:
U+0068 U+0065 U+006C U+006C U+006F
我们可以设想一种非常简单的编码方法:直接把码点标识符写入文件。由于字符总数超过 100 万,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 倍,导致文件很大并充满零。零还会带来另一个问题:在历史上使用 0 字节作为字符串结束标记的 ASCII 系统中,任何全零字节都会破坏向后兼容性。
最常见的编码标准是 UTF-8(Unicode Transformation Format 8,Unicode 转换格式 8)。它通过让某些字符使用较少字节、另一些字符使用较多字节,以较少的平均字节数高效表示字符。因此,UTF-8 是一种变长编码(variable-length encoding)。
对于一部分字符,即前 128 个码点(ASCII 字符集),UTF-8 使用单个字节编码。因此,hello 的 UTF-8 编码是:
68 65 6C 6C 6F
这恰好意味着,以 ASCII 编码的文件也是合法的 UTF-8 编码文件!
不过,UTF-8 是变长编码,因此从码点 128 开始的字符会编码为两个、三个或四个字节的序列。这些字节的值都在 128 到 255 之间,不会与 ASCII 混淆;而且,每个字节最前面的几位会指明它属于 2 字节、3 字节还是 4 字节编码。
| 码点 | UTF-8 编码 | ||||
| 起止范围 | 位值 | 字节 1 | 字节 2 | 字节 3 | 字节 4 |
| U+0000-U+007F | 0xxxxxxx | 0xxxxxxx | |||
| U+0080-U+07FF | 00000yyy yyxxxxxx | 110yyyyy | 10xxxxxx | ||
| U+0800-U+FFFF | zzzzyyyy yyxxxxxx | 1110zzzz | 10yyyyyy | 10xxxxxx | |
| U+010000-U+10FFFF | 000uuuuu zzzzyyyy yyxxxxxx | 11110uuu | 10uuzzzz | 10yyyyyy | 10xxxxxx |
图 2.5 Unicode 码点到变长 UTF-8 编码的映射。对于“起止范围”内的给定码点,第 2 列中的位值会被装入 1、2、3 或 4 个字节。改编自 Unicode 16.0 核心规范第 3 章表 3-6。
图 2.5 展示了这种映射如何进行。例如,这些规则说明了如何处理字符 ñ,其码点为 U+00F1。由于 U+00F1 位于 U+0080 与 U+07FF 之间,我们查看表格第二行。接着,取 U+00F1 对应的位序列,并将其映射到第二列中的 y 值和 x 值。本例的序列为 00000000 11110001(其中相应部分分别构成 yyyyy 与 xxxxxx)。最后,表格右侧说明如何把这一位序列编码成双字节位序列 11000011 10110001,对应 0xC3B1。按照这些规则,前 128 个字符(ASCII)映射为一个字节;欧洲、中东和非洲文字中的大多数其余字符映射为两个字节;大多数中日韩字符映射为三个字节;较少见的 CJKV 字符、表情符号和部分其他符号则映射为四个字节。
UTF-8 有许多优点。它的效率较高,会用较少字节表示常见字符;除非确实在表示码点为 U+0000 的 NULL 字符,否则不会使用零字节;它向后兼容 ASCII;而且具有自同步(self-synchronizing)特性。也就是说,即使文件发生损坏,只要向左或向右移动最多 3 个字节,总能找到前一个或后一个字符的起点。
**Unicode 与 Python:**从 Python 3 开始,所有 Python 字符串都在内部存储为 Unicode,每个字符串都是一系列 Unicode 码点。因此,字符串函数和正则表达式都原生作用于码点。例如,字符串的 len() 等函数返回的是以字符(即码点)计的长度,而不是以字节计的长度。
不过,从文件读取或向文件写入时,码点必须使用 UTF-8 等方法进行编码和解码。也就是说,每个文件都采用某种编码。如果不是 UTF-8,就可能是 ASCII 或 Latin-1(ISO 8859-1)等较早的编码方法。不存在“不带编码的文本文件”。在 Python 中打开文件进行读写时,需要指定编码方法。