字符集编码(三):Unicode
阅读原文时间:2022年03月01日阅读:1

前面《字符集编码(上):Unicode 之前》我们讲了在二十世纪九十年代 Unicode 出现之前各厂商和标准化组织为了应对不同语言文字的编码需求而设计了各种互不兼容的字符集编码标准,这使得软硬件开发商在处理多语言环境时相当棘手。为了解决字符集编码各自为政的乱象,一些利益相关公司开始凑到一起试图设计一种新型的、可囊括全世界所有字符的统一编码标准。

开端

1987 年,苹果(Apple)和施乐(Xerox)两家公司的三个技术人员凑到一起,着手研究开发通用字符集的可行性。在 1987 年末到 1988 年初的几个月里,他们进行了一系列的调查统计,主要弄清这么几个事情:

  • 该字符集大约需要包含多少个字符?
  • 目前世界上使用双字节编码的字符数量?
  • 应该采用固定宽度(定长编码)还是混合宽度(变长编码)的编码方式?
  • 中、日、韩的表意文字可以统一吗(即同样的字符同时出现在中日韩三种语言中,是否可以只编码一次)?

在设计统一码的时候,世界上已经存在大量的双字节编码标准(如 GB 2312、Shift JIS、Big5 等东亚编码标准),而且当时施乐也设计了一套双字节的多语言编码标准,所以最初设计统一码时,大家是倾向于使用双字节的,而双字节最多能表示的字符数量是 2^16 = 65536 个,所以接下来的重点就是验证全世界的字符总数是否大于这个数。

工作小组的验证结果是肯定的(虽然在我们看来有点出乎意料,因为光汉字就不止这个数目了)。当时工作组的原则是只考虑现代字符(也就是现代语言文字中在使用的,不考虑古埃及、古巴比伦、古汉语等现代语言不再使用的字符),而且倾向于使用字符组合(而不是对复杂字符单独编码),比如西班牙语的 ñ 由 n 和 ~ 两个字符组成,大量的韩语也是组合出来的(其实大家还考虑过通过偏旁部首组合汉字,但发现汉字构造过于复杂,就放弃了)。在这些原则下,工作组统计了当时全世界的报纸等刊物,得出了一个结论:两个字节足以囊括全世界有实用意义的字符。

早期工作组的另一个设计倾向是采用定长编码形式

决定采用双字节编码后,面临两个选择:一是采用变长编码形式(类似 GB 2312),对于 ASCII 字符使用一个字节,其他字符使用两个字节;另一种是采用定长编码形式,不管是不是 ASCII 字符统一使用两个字节。

工作组主要从存储和处理效率的角度验证两种方案的优劣,其验证结论是双字节定长编码所带来的文本尺寸增长是可接受的(和其它信息如文本格式信息、图片、视频等比较起来,字符本身的编码信息占用的空间小的可怜),而定长编码形式在各方面的处理效率都优于变长编码形式,所以早期 Unicode 采用了定长编码形式。

工作组面临的第三个问题是汉字

东亚国家由于受到中化文化的影响,它们的语言中包含了一些汉字字符(如日语、韩语),这些(不同语言)中的相同字符(汉字)到底是算作同一个字符呢还是不同字符呢?

在上一篇讨论字符编码模型的文章中我们说过,字符集编码(特别是 Unicode)是对抽象字符编码,而不是对字形或者字意编码。按照这个原则,这些汉字应该算同一个字符,虽然它们在含义上不同(可能在字形上也存在些许差异)。

所以工作组决定将中日韩语言中的汉字部分合并叫中日韩统一表意文字(CJK,中日韩三种语言的首字母)——所以你在 Unicode 区块表中是搜不到诸如 “Han”、“Chinese” 的,汉语是在 CJK 和 CJK 扩展块中。

Unicode 将中日韩汉字合并这点是有争议的。因为 Unicode 是针对抽象字符而非字形编码,而同一个汉字在不同语言中写法(字形)可能不同(比如中国汉字“带”在日语中写作“帯”,以及同一个字的古今写法可能也不同),对于一些写法不同的汉字 Unicode 也认为是同一个字符。所以有些人认为这种合并会让人觉得语言本身失去了独立性。

最初的调研成果总结起来就是:

  1. 使用双字节定长编码;
  2. 中日韩汉字合并编码;

这些研究成果于 1988 年 8 月以草案的形式发布(后称为 Unicode 88),在该草案中正式使用 “Unicode” 一词,中文翻译为“统一码“。

后来,越来越多的公司加入到这个工作组中,包括 Sun、微软、NeXT 等。这些公司于 1991 年 1月决定在加州成立一个正式的联盟,就叫Unicode 联盟(Unicode Consortium),并于同年 10 月发布了 Unicode 的第一版(Unicode 1.0.0。注意 88 年的那个叫草案,只是公布了最初研究成果,很多工作还没完成呢)。该版仅包含 24 种语言文字共 7163 个字符。

注意第一版 Unicode 标准中并不包含 CJK 字符——此时 CJK 部分的工作尚未完成。CJK 部分是在第二年(1992 年 6 月)的 Unicode 1.0.1 中加入的(此版本包含 20902 个汉字)。

如今(2022 年 2 月)最新版 Unicode 已经到了 14.0.0(于 2021 年 9 月发布),支持 159 种文字,共包含 144,697 个字符(包括控制字符、文字符号、表情符号等)。

另一个兄弟

Unicode 联盟并不孤单,当这帮人在设计 Unicode 标准时,另一个组织也在干同样的事情。

ISO 和 IEC 两家组织在 1984 年就成立了一个联合工作组来设计一套新的统一字符集——也就是后来的 UCS(Universal Character Set),于 1989 年公布了 UCS 草案(Unicode 工作小组在 1988 年发布了 Unicode 草案)。

这两个工作组起初谁也不知道对方的存在,直到双方公布了各自的设计草案并被传阅后,大家才逐渐意识到两者干的是同样的事情,这会导致世界上存在两套统一编码字符集标准,对双方以及对世人都没有好处。

于是这两个工作组于 1991 年开始谋求合并方案。

所谓合并,并不是简单地一个工作组完全抛弃自己的东西去拥抱另一个工作组的标准。如果你花费大量人力物力搞了个东西,然后发现别人也搞了个几乎一样的,你愿意完全放弃自己的这套?另外虽然两个组织都奔着相同的目标(统一字符集),但在设计原则和一些细节上还是有很多不同的,更麻烦的是,当大家坐下来谈合并事项时,Unicode 1.0 已经发布了,UCS 还处于草案阶段,但也离发布不远了。出于各方面的原因,合并的前提是仍然保持两个标准都独立存在和发展,在此基础上保持两个标准的兼容和同步(或者其中一个是另一个的子集,或者两个在编码字符集 CCS 层面完全一致)。

和 Unicode 使用 16 位编码空间不同,UCS 一开始就选择使用 31 位编码空间,也就是说,UCS 最多可以容纳 2^31 约 21 亿个字符。最开始,Unicode 打算作为 UCS 的真子集,即 Unicode 中的每个字符都存在于 UCS 中,而且两者的码点相同,但 UCS 中的字符(编码超过 64K 的)则不一定存在于 Unicode 中。

经过多次的争论、投票,最终双方都作出了一些妥协,在字符集层面达成了一致,即两个标准中相同字符的编码(码点)必须是一样的,Unicode 针对 1.0 版本做了一些调整(如调整一些字符的编码,调整某些语言文字的区块),最终 ISO/IEC 在 1993 年发布了 UCS 的第一个版本 ISO/IEC 10646-1:1993,Unicode 联盟也在同一年发布了兼容版本 Unicode 1.1。

当然,合并工作是分步进行的,合并工作成果也是分版本发布的,1993 年的版本发布只是双方初步的(也是最重要的)合并成果发布。

Unicode 概览

网上有一种提问:“Unicode 和 UTF-8 是什么关系?”

很多回答说:“Unicode 是字符编码,UTF-8 是编码实现。”

这种回答并不准确。Unicode 是字符编码不错,但一般人理解的字符编码仅仅是指给字符编号(字符编码模型中的第二层),这给人感觉好像 Unicode 和 UTF-8 是两个平行的、独立的东西。实际情况是 Unicode 标准囊括了字符编码模型的所有层次,从抽象字符集的定义到计算机编码方案,UTF-8 属于 Unicode 标准中编码实现部分。

Unicode 设计之初只打算对人类正在使用的字符进行编码,而不考虑曾经使用但现在已经废弃的古文字,因而起初觉得双字节已经足够。不过很快大家就发现该思路走不通,因为一些文字符号虽然在日常生活中用不到,但在特殊领域会用到(比如历史、考古、语言学等),如果 Unicode 不包含这些字符,则这些特殊领域就必须设计另外的字符集标准,这有违 Unicode 初衷。于是很快 Unicode 联盟就决定拓宽 Unicode 编码空间,从 16 位拓宽到 21 位,共可表示一百多万的字符(其中有些空间属于保留空间或私人空间,不可分配码点)。

Unicode 不对非人类语言文字编码,比如腾格瓦语(Tengwar,《魔戒》作者托尔金创造的精灵语文字)、克林贡语(Klingons,《星际迷航》中克林贡人使用的语言)。

一个抽象字符可能有多种形状(字形),但由于都具有相同的含义,在 Unicode 中被视为同一个字符,只会分配一个码点,比如汉字有楷、行、草、隶等写法,这些属于同一个字符的不同字形。需要注意,这里所说的“字形”是指不同的书写方式导致的字形差异,而非结构性差异,比如繁体中文和简体中文,从字源来说,简体中文是对繁体中文的简化写法,属于同意不同字,但这两者的差距是体现在结构上而非书写形式上,所以要视为不同的字符。

一个字形到底是由一个字符构成的,还是由多个字符组合成的,是一件见仁见智的事情。比如字符 é,你可以认为它是一个独立的字符,也可以认为是由拉丁字母 e 和音调字符 ́ 构成。传统编码倾向于作为独立字符看待,而 Unicode 倾向于作为组合字符——Unicode 倾向于使用简单字符组合复杂字符

另外,抽象字符不一定就存在可视化的字形,比如控制字符。

在 Unicode 字符集中,每个抽象字符都有唯一的名称,使用大写 ASCII 字符表示,比如拉丁字母 a 在 Unicode 中的名称是“LATIN SMALL LETTER A”。可在 http://www.unicode.org/Public/UNIDATA/NamesList.txt 查看所有字符的名称。

Unicode 使用整型数值对这些抽象字符编码,在书写上,用数值的十六进制表示,且至少是 4 位,少于 4 位的使用前导 0 填充,比如 61 要写成 0061。另外要在数值前面加上 U+ 表示是 Unicode 码点,因而拉丁字母 a 的 Unicode 码点写作 U+0061。

数值编码(码点)可能的范围叫编码空间(codespace)。起初 Unicode 的编码空间是 U+0000 ~ U+FFFF,大家很快发现 64K 的编码空间根本不够用,所以后来将编码空间扩大到了 U+0000 ~ U+10FFFF,可容纳一百多万的字符。

这一百多万的编码空间被划分成 17 个平面(planes,17 个大小相同的区域,编号 0 ~ 16),每个平面可容纳 2^16 即 65,536 个码点。每个平面的作用不一样:

其中最重要的平面是基本平面 Plane 0,也叫 BMP,全世界日常使用的字符都在该平面中(早期 Unicode 就是以该平面作为整个编码空间);Plane 2 和 Plane 3 是给汉字扩展用的;最后两个平面是私有编码空间(PUA),不会分配字符码点,专门给软件自定义用的。

这些平面又进一步划分成(block),每个块放一组特定的字符,如 0000~007F 放基本拉丁字母(ASCII 字母),0590~05FF 放希伯来文,4E00~9FFF 放中日韩统一表意文字(我们最常用的汉字就是在这个块)

# Unicode 字符块(部分)

0000—007F 基本拉丁字母
0080—00FF 拉丁文补充1
0100—017F 拉丁文扩展A
0180—024F 拉丁文扩展B
...
0370—03FF 希腊字母及科普特字母
0400—04FF 西里尔字母
0500—052F 西里尔字母补充
0530—058F 亚美尼亚字母
0590—05FF 希伯来文
0600—06FF 阿拉伯文
...
0E00—0E7F 泰文
0E80—0EFF 老挝文
0F00—0FFF 藏文
1000—109F 缅甸文
...
2200—22FF 数学运算符
...
2E80—2EFF 中日韩部首补充
2F00—2FDF 康熙部首
2FF0—2FFF 表意文字描述符
...
4DC0—4DFF 易经六十四卦符号
4E00—9FFF 中日韩统一表意文字
A000—A48F 彝文音节
A490—A4CF 彝文字根
...

一种语言文字可能分散在多个块中,如汉字就存在很多扩展块,不过最常用的汉字都是在 4E00~9FFF 中。

其中一个扩展块中的汉字,都是我们没见过的,平时根本用不到

Unicode 的三种表示形式

Unicode 设计之初是采用双字节定长编码的,其码点和计算机层面表示形式(编码模式中的第三层 CEF)是一致的。比如汉字的“汉”的 Unicode 码点是 U+6C49,其计算机编码表示就是 6C49——这就是 UTF-16 的早期样子。这种编码方式的优点是高效,不需要检查标志位。

后来大家发现 16 位编码空间根本不够用,于是将编码空间拓展到 21 位。由于原始的 UTF-16 编码形式无法表示大于 FFFF 的码点,于是对 UTF-16 也进行了拓展,使其既能用 1 个码元表达 BMP 中的字符,也能用 2 个码元表示补充平面的字符——这就是现代版本的 UTF-16。

在 Unicode 认为自己的 16 位编码空间太小的同时,ISO/IEC 也觉得 UCS 的 31 位编码空间太多了,实际中根本没有几十亿字符。所以最终 Unicode 联盟和 ISO/IEC 工作组达成一致:两者使用统一的编码空间 0000 ~ 10FFFF(即 UCS 保证永远不分配大于 10FFFF 的字符码点),而且双方在字符编码上保持同步,即一方标准中增加了字符,也要通知另一方同步。

使用双字节定长编码还存在另一个——可能是更要命的——问题:它无法在编码形式层面兼容 ASCII 码。虽然 Unicode 在码点层面(第二层)兼容 ASCII(U+0000~U+007E 的码点分配和 ASCII 一致),但由于在计算机编码层面,Unicode 使用两个字节,而 ASCII 使用一个字节,这导致采用 Unicode 编码标准的软件无法正确处理现有的 ASCII 编码文件。

Unicode 1991 年才发布,ASCII 在 1968 年就发布了,这二十多年间产生了大量的 ASCII 文件和使用 ASCII 标准的软件,Unicode 置这些现存文件和软件不顾的后果就是新兴的 Unicode 标准很难被全世界(特别是计算机重度使用区欧美)广泛接受。Unicode 要想快速普及,就必须完全兼容 ASCII,因而 Unicode 联盟很快推出了 8 bit 码元编码方案:UTF-8。UTF-8 和改进后的 UTF-16 一样是变长编码方式,ASCII 字符采用单字节编码(最高位是 0),其它字符可能采用 2~4 字节,比如常用汉字用 3 字节(一些不常用汉字会用到 4 字节)。

关于为何现在 UTF-8 成为 Unicode 标准中最广泛使用的编码方式,网上大多数的回答都是说因为 UTF-8 在编码拉丁字母时更节约空间,所以欧美公司倾向于使用 UTF-8。

在我看来,这可能是原因之一,但并非主因。

节约空间并不是导致 UTF-8 被广泛使用的主因。想一想当时设计 Unicode 的都是谁,苹果、施乐、微软等等,这些都是当时或未来计算机行业的代表,他们肯定是按照自己的实际诉求来设计 Unicode 的,是在做了充分调研、实际测试验证后,得出双字节编码并不会造成拉丁语系文本空间显著增长的结论,才决定用双字节定长编码。

真正让 UTF-8 广为接受的恰恰就是历史遗留下来的那些 ASCII 文本和程序(以及操作系统、编程语言)。

这个论点从直觉上可能觉得不可思议——因为人们直觉总是觉得过去无关紧要,现在和未来才是重要的(比如 Unicode 设计之初就不打算考虑古文字,选用双字节编码也是不打算彻底兼容 ASCII),然而真正决定未来的往往就是历史——人类文明如此,项目的成败亦是如此。

我们必须正视两个事实:1. Unicode 比 ASCII 晚了二十多年;2. 其他传统编码基本上都兼容 ASCII。这两点导致在 Unicode 发布的时候,世界上必然存在大量的 ASCII 文本和使用或兼容 ASCII 的软件。

于是 Unicode 出来后,各大软硬件厂商面临几个选择:

  • 完全拥抱 Unicode,这将造成新旧不兼容(比如文字处理软件 2.0 无法处理 1.0 生成的文件),这很可能导致新产品卖不出去;
  • 完全不用 Unicode,以前该怎么苦逼继续怎么苦逼;
  • 开发转换工具,为新老文本和工具做双向转换(但这种方式无疑是别扭的);

如果是你,你会怎么选呢?有可能是新产品用 Unicode,老产品继续用老编码标准,也有可能直接不用 Unicode——因为你还要考虑公司产品之间的兼容性问题。

所以当各大公司发现 UTF-8 能完美解决兼容性问题,自然都跑去用 UTF-8 了。大家都用,那 UTF-8 自然就被推广开了,而且人家都用,你不用,你的产品在跟别家互操作时就会出现兼容性问题,进而就会被市场淘汰——所以你也必须用。

我们用上帝视角设想,假如 1968 年的那个标准不是单字节编码的 ASCII 而是双字节编码的 Unicode(虽然从历史环境来说不太可能),那么很可能今天就压根没有 UTF-8 编码。UTF-8 编码也仅仅是在 ASCII 字符区域节约空间,在拉丁扩展区域(也就是欧洲拉丁字母)和 UTF-16 一样占两个字节,而在常用汉字区域则比 UTF-16 多使用一个字节。全世界最常用的字符都是在基本平面 BMP 中,UTF-16 在该平面恒定使用两个字节编码,其效率近似于定长编码方式,而 UTF-8 则使用 1~3 个字节编码,是真正的变长编码——文字处理软件可以针对 UTF-16 做定长假定优化,对 UTF-8 则不行(也即是说,文字处理软件可以假定 UTF-16 文本都是双字节编码,当真的遇到四字节时再做特殊处理,但不能对 UTF-8 这么做)。

所以,UTF-8 之所以会胜出,不是因为 UTF-8 在技术上比 UTF-16 有多大优势(虽然 UTF-8 设计得很巧妙),而是因为 UTF-8 在那时解决了各大公司的痛点——更准确地说,UTF-8 是为了解决大家的痛点才出现的。

参考资料:UTF-8 history; Early Years of Unicode;

另外,UCS 一开始就是支持 32 位码元的(人家从一开始就是 31 位编码空间),为了和 UCS 保持一致,Unicode 也支持 32 位码元:UTF-32。

UTF-X 中的 X 表示码元位数。

Unicode 在 2.0(1996 年) 中正式引入了 UTF-8 和改进后的 UTF-16,在 3.1(2001 年) 版本中引入了 UTF-32。

我们在下一篇将详细介绍三种编码方式的实现细节,此处仅做概要介绍。

妥协

Unicode 有两个设计原则:

  1. 抽象字符原则:面向抽象字符而不是字形或字意编码;
  2. 动态组合原则:使用简单的字符组合复杂字符,而不是为复杂字符单独编码;

为了让 Unicode 能够被广泛地接受,Unicode 联盟在设计之初做了一项重要决定:Unicode 必须完全兼容现有的所有字符集编码标准。这个兼容是“双程”的:任何现有字符集中的任何一个字符,可以转换成 Unicode 字符集中的字符,并且从 Unicode 中的这个字符再转换回去后还是原来那个字符,这个规则称为 round-trip rule。

这个规则让 Unicode 在实现上做了很多妥协。

我们在上篇文章中举过拉丁字母 K 的例子。在一些传统的编码标准中,拉丁字母 K 和热力学单位 K(开尔文)被当做两个不同的字符,

为了实现 round-trip 规则,Unicode 中也必须编码两个 K(分别是 Latin Capital Letter K U+004B 和 KELVIN SIGN U+212A)——否则那个传统编码中的两个 K(在那边的码点是不一样的)转换成 Unicode 编码后变成同一个 K 了,再转回去就不知道对应谁了。

类似的情况在汉语中也有很多。比如 U+2F08 和 U+319F 都是汉字“人”,U+2F17 和 U+3038 都是汉字“十”,U+03A9 和 U+2126 都是 Ω(一个是希腊字母,一个是电阻符号)。

中国的 GB 编码和日本的 JIS 编码在兼容 ASCII 的同时,又给 ASCII 中的可见字符做了个“全角”编码(原 ASCII 中的字符被称为“半角”字符)。所谓全角和半角字符,在字形和字意上都完全相同,只是全角字符占用宽度(注意不是字形本身的宽度)是半角字符的两倍(据说是为了中英文混排时的美观效果),按照 Unicode 的设计原则,这种问题应该交由文字渲染程序去处理,但由于传统编码标准中做了独立编码,所以 Unicode 中也必须支持,在 Unicode 编码表中也能看到一系列 Full Width 的拉丁字母。

另外有些传统编码标准中对不同书写体的拉丁字母也做了不同编码,Unicode 同样需要兼容之。

Unicode 中同一个字符的不同字形或字意被重复编码。注意源自东亚标准中的 FULL WIDTH

这些重复编码违背了 Unicode 设计中的抽象字符原则

在传统编码中很少有组合字符的说法,所以诸如 Å(瑞典语字符,以及长度单位“埃”)在传统编码(如 ISO/IEC 8859)中视作一个独立字符,但在 Unicode 中视作两个字符 A(U+0041)和 ̊(U+030A)的组合字符。为了兼容传统编码,Unicode 在支持组合的同时,还必须将该字形视作单独的字符分配额外码点(U+00C5)——Unicode 中称这种字符为预合成字符(precomposed character)。

Å 不但存在动态组合与预合成的问题,该字符本身由于在一些传统编码标准中作为长度单位“埃”和作为拉丁字母 Å 做了不同的编码,Unicode 中也必须作此重复(U+00C5:LATIN CAPITAL LETTER A WITH RING ABOVE;U+212B:ANGSTROM SIGN)。

Unicode 中存在大量的这种违背动态组合原则的字符。

大部分韩语在 Unicode 中也是可以组合的,所以也存在多种编码的可能。

Unicode 中视预合成字符和动态组合字符是等效的,也就是说,如果文本中存在两个 Å,一个是组合的:,另一个是预合成的:U+00C5,则软件应该将其视为一种字符,搜索的时候两个都应该能搜出来——不过目前貌似很多软件并没有实现这一点。

Unicode 基本概念就介绍到这里,下一篇我们讲讲 Unicode 的三种计算机编码实现:UTF-8、UTF-16 和 UTF-32。

原文链接:字符集编码(中):Unicode