下面这篇文章出自 Mark Pilgrim 所编写的《Dive Into Python3》,这是我所看过的讲解文本编码的最通俗易懂的一篇文章。

前言

I’m telling you this 'cause you’re one of my friends.

My alphabet starts where your alphabet ends!

Dr. Seuss

你是否知道 Bougainville 人有世界上最小的字母表?他们的 Rotokas 字母表只包含了12个字母: A, E, G, I, K, O, P, R, S, T, U, 和 V。另一方面,像汉语,日语和韩语这些语言,它们则有成千上万个字符。当然啦,英语共有26个字母 — 如果把大写和小写分别计算的话,52个 — 外加少量的标点符号,比如!@#$%&

当人们说起“文本”,他们通常指显示在屏幕上的字符或者其他的记号;但是计算机不能直接处理这些字符和标记;它们只认识位(bit)和字节(byte)。实际上,从屏幕上的每一块文本都是以某种字符编码(character encoding)的方式保存的。粗略地说就是,字符编码提供一种映射,使屏幕上显示的内容和内存、磁盘内存储的内容对应起来。有许多种不同的字符编码,有一些是为特定的语言,比如俄语、中文或者英语,设计、优化的,另外一些则可以用于多种语言的编码。

在实际操作中则会比上边描述的更复杂一些。许多字符在几种编码里是共用的,但是在实际的内存或者磁盘上,不同的编码方式可能会使用不同的字节序列来存储他们。所以,你可以把字符编码当做一种解码密钥。当有人给你一个字节序列 — 文件,网页,或者别的什么 — 并且告诉你它们是“文本”时,就需要知道他们使用了何种编码方式,然后才能将这些字节序列解码成字符。如果他们给的是错误的“密钥”或者根本没有给你“密钥”,那就得自己来破解这段编码,这可是一个艰难的任务。有可能你使用了错误的解码方式,然后出现一些莫名其妙的结果。

你肯定见过这样的网页,在撇号(’)该出现的地方被奇怪的像问号的字符替代了。这种情况通常意味着页面的作者没有正确的声明其使用的编码方式,浏览器只能自己来猜测,结果就是一些正确的和意料之外的字符的混合体。如果原文是英语,那只是不方便阅读而已;在其他的语言环境下,结果可能是完全不可读的。

现有的字符编码各类给世界上每种主要的语言都提供了编码方案。由于每种语言的各不相同,而且在以前内存和硬盘都很昂贵,所以每种字符编码都为特定的语言做了优化。上边这句话的意思是,每种编码都使用数字(0–255)来代表这种语言的字符。比如,你也许熟悉ascii编码,它将英语中的字符都当做从0–127的数字来存储。(65表示大写的“A”,97表示小写的“a”,&c。)英语的字母表很简单,所以它能用不到128个数字表达出来。如果你懂得2进制计数的话,它只使用了一个字节内的7位。

西欧的一些语言,比如法语,西班牙语和德语等,比英语有更多的字母。或者,更准确的说,这些语言含有与变音符号(diacritical marks)组合起来的字母,像西班牙语里的ñ。这些语言最常用的编码方式是CP-1252,又叫做“windows-1252”,因为它在微软的视窗操作系统上被广泛使用。CP-1252和ascii在0–127这个范围内的字符是一样的,但是CP-1252为ñ(n-with-a-tilde-over-it, 241),Ü(u-with-two-dots-over-it, 252)这类字符而扩展到了128–255这个范围。然而,它仍然是一种单字节的编码方式;可能的最大数字为255,这仍然可以用一个字节来表示。

然而,像中文,日语和韩语等语言,他们的字符如此之多而不得不需要多字节编码的字符集。即,使用两个字节的数字(0–255)代表每个“字符”。但是就跟不同的单字节编码方式一样,多字节编码方式之间也有同样的问题,即他们使用的数字是相同的,但是表达的内容却不同。相对于单字节编码方式它们只是使用的数字范围更广一些,因为有更多的字符需要表示。

在没有网络的时代,“文本”由自己输入,偶尔才会打印出来,大多数情况下使用以上的编码方案是可行的。那时没有太多的“纯文本”。源代码使用ascii编码,其他人也都使用字处理器,这些字处理器定义了他们自己的格式(非文本的),这些格式会连同字符编码信息和风格样式一起记录其中,&c。人们使用与原作者相同的字处理软件读取这些文档,所以或多或少地能够使用。

现在,我们考虑一下像email和web这样的全球网络的出现。大量的“纯文本”文件在全球范围内流转,它们在一台电脑上被撰写出来,通过第二台电脑进行传输,最后在另外一台电脑上显示。计算机只能识别数字,但是这些数字可能表达的是其他的东西。Oh no! 怎么办呢。。好吧,那么系统必须被设计成在每一段“纯文本”上都搭载编码信息。记住,编码方式是将计算机可读的数字映射成人类可读的字符的解码密钥。失去解码密钥则意味着混乱不清的,莫名其妙的信息,或者更糟。

现在我们考虑尝试把多段文本存储在同一个地方,比如放置所有收到邮件的数据库。这仍然需要对每段文本存储其相关的字符编码信息,只有这样才能正确地显示它们。这很困难吗?试试搜索你的email数据库,这意味着需要在运行时进行编码之间的转换。很有趣是吧…

现在我们来分析另外一种可能性,即多语言文档,同一篇文档里来自几种不同语言的字符混在一起。(提示:处理这样文档的程序通常使用转义符在不同的“模式(modes)”之间切换。噗!现在是俄语 koi8-r 模式,所以241代表 Я;噗噗!现在到了Mac Greek模式,所以241代表 ώ。)当然,你也会想要搜索这些文档。

现在,你就哭吧,因为以前所了解的关于字符串的知识都是错的,根本就没有所谓的“纯文本”。

Unicode

Unicode 编码系统为表达任意语言的任意字符而设计。它使用4字节的数字来表达每个字母、符号,或者表意文字(ideograph)。每个数字代表唯一的至少在某种语言中使用的符号。(并不是所有的数字都用上了,但是总数已经超过了65535,所以2个字节的数字是不够用的。)被几种语言共用的字符通常使用相同的数字来编码,除非存在一个在理的语源学(etymological)理由使不这样做。不考虑这种情况的话,每个字符对应一个数字,每个数字对应一个字符。即不存在二义性。不再需要记录“模式”了。U+0041总是代表 ‘A’,即使这种语言没有 ‘A’ 这个字符。

初次面对这个创想,它看起来似乎很伟大。一种编码方式即可解决所有问题。文档可包含多种语言。不再需要在各种编码方式之间进行“模式转换”。但是很快,一个明显的问题跳到我们面前。4个字节?只为了单独一个字符?这似乎太浪费了,特别是对像英语和西语这样的语言,他们只需要不到1个字节即可以表达所需的字符。事实上,对于以象形为基础的语言(比如中文)这种方法也有浪费,因为这些语言的字符也从来不需要超过2个字节即可表达。

有一种 Unicode 编码方式每 1 个字符使用 4 个字节。它叫做 UTF-32,因为32位 = 4字节。UTF-32是一种直观的编码方式;它收录每一个Unicode字符(4字节数字)然后就以那个数字代表该字符。这种方法有其优点,最重要的一点就是可以在常数时间内定位字符串里的第N个字符,因为第N个字符从第4×Nth个字节开始。另外,它也有其缺点,最明显的就是它使用4个“诡异”的字节来存储每个“诡异”的字符…

尽管 Unicode 字符非常多,但是实际上大多数人不会用到超过前65535个以外的字符。因此,就有了另外一种 Unicode 编码方式,叫做UTF-16(因为16位 = 2字节)。UTF-16将0–65535范围内的字符编码成2个字节,如果真的需要表达那些很少使用的“星芒层(astral plane)”内超过这65535范围的Unicode字符,则需要使用一些诡异的技巧来实现。UTF-16编码最明显的优点是它在空间效率上比UTF-32高两倍,因为每个字符只需要2个字节来存储(除去65535范围以外的),而不是UTF-32中的4个字节。并且,如果我们假设某个字符串不包含任何星芒层中的字符,那么我们依然可以在常数时间内找到其中的第N个字符,直到它不成立为止这总是一个不错的推断…

但是对于 UTF-32 和 UTF-16 编码方式还有一些其他不明显的缺点。不同的计算机系统会以不同的顺序保存字节。这意味着字符U+4E2D在UTF-16编码方式下可能被保存为4E 2D或者2D 4E,这取决于该系统使用的是大尾端(big-endian)还是小尾端(little-endian)。(对于UTF-32编码方式,则有更多种可能的字节排列。)只要文档没有离开你的计算机,它还是安全的 —— 同一台电脑上的不同程序使用相同的字节顺序(byte order)。但是当我们需要在系统之间传输这个文档的时候,也许在万维网中,我们就需要一种方法来指示当前我们的字节是怎样存储的。不然的话,接收文档的计算机就无法知道这两个字节4E 2D表达的到底是U+4E2D还是U+2D4E。

为了解决这个问题,多字节的Unicode编码方式定义了一个“字节顺序标记(Byte Order Mark)”,它是一个特殊的非打印字符,你可以把它包含在文档的开头来指示你所使用的字节顺序。对于UTF-16,字节顺序标记是U+FEFF。如果收到一个以字节FF FE开头的UTF-16编码的文档,你就能确定它的字节顺序是单向的(one way)的了;如果它以FE FF开头,则可以确定字节顺序反向了。

不过,UTF-16还不够完美,特别是要处理许多ascii字符时。如果仔细想想的话,甚至一个中文网页也会包含许多的ascii字符 — 所有包围在可打印中文字符周围的元素(element)和属性(attribute)。能够在常数时间内找到第Nth个字符当然非常好,但是依然存在着纠缠不休的星芒层字符的问题,这意味着你不能保证每个字符都是2个字节长,所以,除非你维护着另外一个索引,不然就不能真正意义上的在常数时间内定位第N个字符。另外,朋友,世界上肯定还存在很多的ascii文本…

另外一些人琢磨着这些问题,他们找到了一种解决方法:

The range of integers used to code the abstract characters is called the codespace. A particular integer in this set is called a code point. When an abstract character is mapped or assigned to a particular code point in the codespace, it is then referred to as an encoded character.

UTF-8是一种为Unicode设计的变长(variable-length)编码系统。即,不同的字符可使用不同数量的字节编码。对于ascii字符(A-Z, &c.)utf-8仅使用1个字节来编码。事实上,utf-8中前128个字符(0–127)使用的是跟ascii一样的编码方式。像ñ和ö这样的“扩展拉丁字符(Extended Latin)”则使用2个字节来编码。(这里的字节并不是像UTF-16中那样简单的Unicode编码点(unicode code point);它使用了一些位变换(bit-twiddling)。)中文字符比如“中”则占用了3个字节。很少使用的“星芒层字符”则占用4个字节。

缺点:因为每个字符使用不同数量的字节编码,所以寻找串中第N个字符是一个O(N)复杂度的操作 — 即,串越长,则需要更多的时间来定位特定的字符。同时,还需要位变换来把字符编码成字节,把字节解码成字符。

优点:在处理经常会用到的ascii字符方面非常有效。在处理扩展的拉丁字符集方面也不比UTF-16差。对于中文字符来说,比UTF-32要好。同时,(在这一条上你得相信我,因为我不打算给你展示它的数学原理。)由位操作的天性使然,使用UTF-8不再存在字节顺序的问题了。一份以utf-8编码的文档在不同的计算机之间是一样的比特流。

补充:字符集和编码

下面这篇短文节选自《LaTeX Notes雷太赫排版系统简介》。

众所周知电脑内部采用二进制编码,因为它易于用电子电路实现。所有字符在电脑内部都是用二进制表示的,字符集的二进制编码被称为字符编码,有时人们也会混用这两个术语。

1963 年 ANSI发布了基于电报码的 ASCII,这就是最早的字符编码,它用 7 位 (bit) 表示了 27 = 128 个字符,只能勉强覆盖英文字符。美国人发明了电脑,他们优先考虑英语是可以理解的。后来随着电脑技术的传播,人们呼吁把字符编码扩充到 8 位也就是一个字节 (byte) ,可以涵盖 28 = 256 个字符。

于是 ISO 在 1980 年代中期推出了 ISO 8859,256 个字符显然也不能满足需要,所以 8859 被分为十几个部分:从 8859-1 (西欧语言) 、8859-2 (中 欧语言) ,直到 8859-16 (东南欧语言) ,覆盖了大部分使用拉丁字母的语言文字。

在 ISO 标准完全定型之前,IBM 就有一系列自己的字符编码,也就是代码页 (code page) ,比如 437 (扩展 ASCII) 、850 (西欧语言) 、852 (东欧语 言) 。IBM 代码页通常被用于控制台 (console) 环境,也就是 MS-DOS 或 Unix Shell 那样的命令行环境。

微软将 IBM 代码页称为 OEM 代码页,自己定义的称为 ANSI 代码页,比如 1252 (西欧语言) 、1250 (东欧语言) 、936 (GBK 简体中文) 、950 (Big5 繁体中文) 、932 (SJIS 日文) 、949 (EUC-KR 韩文) 等。

1981 年,中国大陆推出了第一个自己的字符集标准 GB2312,它是一个 94×94 的表,包括 7445 个字符。GB2312 通常采用双字节的 EUC-CN 编码,所以后者也常常被称为 GB2312 编码;其实 GB2312 还有另一种编码方式 HZ,只是不常用。

GB2312 中没有朱镕基的“镕”字,于是它在 1993 年被扩展为 GBK,包含 21886 个字符。GBK 不是正式标准。2000 年发布的 GB18030 包含 70244个字符,采用四字节编码。GB18030 之前还出现过一个 GB13000,但是没有形成气候。

1990 年 ISO 推出了通用字符集 (universal character set,UCS) ,即 ISO 10646,意图一统江湖。它的容量是一百多万个字符,目前实际使用的有十万个左右。UCS 有两种编码:双字节的 UCS-2 和四字节的 UCS-4。

ISO 之外还有个希望一统江湖的组织:统一码联盟 (e Unicode Consor-tium) ,它于 1991 年推出了 Unicode 1.0。后来两家组织意识到没必要做重复工作,于是双方开始合并成果,携手奔小康。Unicode 从 2.0 版开始采用与ISO 10646-1 相同的编码。

Unicode 主要有三种编码:UTF-8、UTF-16、UTF-32。UTF-8 使用一至四个 8 位编码。UTF-16 用一或两个 16 位编码,基本上是 UCS-2 的超集,和 ASCII 不兼容。UTF-32 用一个 32 位编码,它是 UCS-4 的一个子集。

IETF 要求所有网络协议都支持 UTF-8,互联网电子邮件联盟 (Internet Mail Consortium,IMC) 也建议所有电子邮件软件都支持 UTF-8,所以它已成为互联网上的事实标准。

其他材料

  1. The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets (No Excuses!)
  2. On the Goodness of Unicode
  3. On Character Strings
  4. Characters vs. Bytes