原文:https://www.joelonsoftware.com/2003/10/08/the-absolute-minimum-every-software-developer-absolutely-positively-must-know-about-unicode-and-character-sets-no-excuses/

注:原文发布于2003年,作者是Joel Spolsky,Stack Overflow的创建者。

你有没有好奇过神秘的Content-Type标签呢?也就是你应该放在HTML里却完全不知道是什么玩意的东西。

你有没有接收到过从保加利亚发来的电邮,上面标题写着“???? ?????? ????”?

为数众多的软件开发者并不能完全明白这个有关字符集,编码,Unicode的迷离世界,这一情况真是让我忧心忡忡。几年前,FogBUGZ的一次beta测试想知道能不能处理日文电子邮件。日文?日本也有电子邮件吗?我可不知道。而当我们仔细翻看了用来做MIME电邮解析的商业版ActiveX控件时,却发现它处理字符集的方式大错特错。所以我们必须得用英雄式的代码把原来错误的转换重置掉,然后再用正确的方法进行重做。我还查看了其他的商业库,它的字符代码实现也是坏掉的。我给这个库的开发者打个了招呼,结果他觉得“事到如今已无法挽回了”。许多开发者正是抱着像他这样的“一切都会过去”的心态。

然而一切都会存在。我发现流行的Web开发工具PHP在字符编码问题上几乎是一无所知,它不过脑子般地用8比特表示字符,使得开发优良的国际网络应用变得难于登天。这时我就暗想,到此为止吧。

我要在此宣布:这都2003年了,如果你是个的程序员却不知道基本的字符、字符集、编码和Unicdoe,我会到你,我会用惩罚你,我要让你在潜水艇里连续剥6个月洋葱。对天发誓我会这样做的。

然后还有就是:这事儿真没那么难

这篇文章就是要让你搞明白每个有工作的程序员都需要掌握的内容。所谓“纯文本就是ASCII,ASCII就等于8比特字符”不仅是错误的,而且还错到天涯海角去了。如果你还是用老样子编程,那你和不承认细菌存在的医生没啥区别。我求你在读完这篇文章之前不要再多写一行代码。

在我开始之前,必须警告你如果你略懂国际化,你会发现我的表述其实是有些过于简化了的。我只是想设置一个最低标准,这样每个人都能读懂,都能有望用代码处理其他语言,而不是英语的子集这种不引入变音符号的语言。我还要警告你字符处理仅仅是国际化软件的一小部分,但事情要一件一件来,今天的主题只是字符集。

史海钩沉

按时间顺序解释是最容易让人理解的方式。

你可能觉得我要讨论非常古早的字符集了,比如EBCDIC。不过我不会这样做,EBCDIC和你的人生没有半毛钱关系。我们不需要回到侏罗纪。
image.png

那么回溯到半古老的年代,彼时Unix已然被发明,Kernighan和Ritchie正在编撰《C编程语言》 一书,一切都是那么简简单单。EBCDIC正逐渐淡出人们的视线。我们所关心的只有英语字母,我们创造了一种名为ASCII的编码,使用32到127之间的数字来表示所有的字符。空格是32,字母“A”则是65,以此类推。这样按照惯例可以用7比特来存储字符。那个时候大部分电脑都是用8比特表示一个字节,所以你不仅可以用一个字节存储所有ASCII码,还留有一个比特的余裕。如果你这个人比较坏,你可以用这个比特干点坏事出来:WordStar(早期文本处理软件)的脑残开发者把多余的高位比特表示单词的最后一个字母,导致WordStar只能处理英文文本。小于32 的码值是不可打印的,用途是咒骂别人。这是玩笑话。这些字符其实是控制字符,比如7代表电脑蜂鸣声,12可以让打印机的当前页弹出,从而新的纸可以被送进去。

如果你说英语的话,这些都挺棒的。

因为字节最多可达8比特,很多人就想,“天啦噜,我们可以按自己的需求使用128到255的字符。”问题是,当时有这想法的人不止一个,每个人脑袋里对如何使用这128到255的空间都有不同的想法。在IBM-PC上有着OEM字符集,提供欧洲语言的变音标记字符和一大堆制表符号,横平竖直的线,拐弯抹角的线等等。这些字符可以在屏幕上绘制整整齐齐的格子和线段,现在你还可以在干洗店的8088电脑上看到它们。实际上,当很多美国之外的消费者购买PC时,各种各样的OEM字符集都冒了出来,它们都按着自己的想法占用着额外的128字符。比如某些PC上130会显示é,但在以色列售卖的电脑上就会显示希伯来文 ג ,所以当美国人把他们的简历(résumé)发给以色列人的时候,后者看到的是rגsumג。在很多例子里,比如俄文,对最大的128个字符的使用也是五花八门,你根本没法可靠地交换俄文文档。
image.png
最后免于OEM压迫的ANSI标准诞生了。在ANSI标准里,所有人都要对小于128的字符映射保持相同意见,基本上和ASCII吻合,但是大于128的字符处理方式则有很多,取决于你所处的地理位置。这些不同的系统被称为 代码页(code page)。比如以色列DOS使用的是代码页862,希腊用户则使用737。小于128的字符是相同的,但高于128的字符是不同的,好玩的字符都高于128。国际版MS-DOS有十余种代码页,可以处理从英文到冰岛文的所有语言,它还具备一些“多语言”的代码页,让同一台电脑支持世界语和加利西亚语!但是让同一台电脑同时显示希伯来文和希腊文还是不可能,除非你自己写一个程序用位图表示所有字符,因为这两种语言使用了不同的代码页,有着不同的高位字符解读。

与此同时,在亚洲,还需要考虑更为疯狂的事情,亚洲字母表上有着上千的字母,根本不可能塞进8个比特里。通常来讲,这个问题可以用DBCS这样的系统解决,即“双字节字符集”,在这种系统里有些字存储为一个字节,有些则是两个。在这样的字符串里向前位移很容易,但是向后移动则不太容易办到。程序员们不用s++s--来前后移动,而是调用函数,比如Windows的AnsiNextAnsiPrev,他们清楚怎么处理这一团乱麻。

但是大部分人都假装一个字节就是一个字符,也就是8比特,只要你不把一台电脑上的字符串移到另一台电脑上,或者只使用单一语言就没问题。然而互联网到来了,它成为了在电脑间移动字符串的首选,麻烦事也随之而来。好在,Unicode应运而生。

Unicode

Unicode是创造单一字符集一统书写系统天下的英勇举措。有些人对Unicode持有错误的概念,认为它不过是16比特的码值而每个字符都是用16比特表示,从而可以表示65,536个字符。这根本是一派胡言。这算是有关Unicode的最为常见的迷思了,你要是这样想我觉得很不好。

实际上Unicode采用了不同的方式来看待字符,你必须理解Unicode处理字符的思路不然下面的一切都是对牛弹琴。

到目前为止,我们都在假设有一张字母映射表把字母映射成比特从而存储在磁盘或内存中:

  1. A->0100 0001

而在Unicode中,字母映射的是 码点(code point),是个理论上的概念。码点如何表示在磁盘或内存上则是另一个故事了。

Unicode的字母 A 是个柏拉图式的理想字母(译注因为作者使用的是英语)。它就那样漂浮在天上:
A
这个柏拉图A和B是不同的,和a也是不同的,但是它和AA以及A都是相同的。Times New Roman字体的A和Helvetica字体的A是一回事,而“a”是不同的字符,这没什么毛病,但在某些语言里,想要搞清楚一个字母倒是是什么可能会导致争议。德文字母ß究竟是一个字母,还是只是ss的花式写法?如果一个字母的形状在单词的末尾发生的了改变,它还是原来那个字母吗?对于希伯来文是这样,但是对阿拉伯文字则并非如此。总而言之,Unciode组织里的那帮子聪明人在差不多过去10年里都在研究这件事,伴随着一场高强度的政治性辩论(你不需要关心这些),他们终于搞清楚该怎么做了。

每一种字母表上的字母都被Unciode组织赋予了一个魔法数字,比如U+0639。这给魔法数字就是码点。U+代表“Unicode”,后面的数字则是16进制数。U+0639是阿拉伯字母Ain。英语中的A是U+0041。你可以在Windows 2000或XP上用charmap工具或者在Unicode官网上找到所有字母表示。
image.png
Unicode对字母个数其实并没有设限,Unicode字母远远超过65,536个所以并不是所有字符都可以被塞进两个字节里面。

举个例子,这里有的字符串:
Hello
在Unicode里,可表示为五个码点:

  1. U+0048 U+0065 U+006C U+006C U+006F

这些就是一堆数字而已。我们还没有说明如何把这些数字存到内存里或者在电邮中表示它们。

编码

这个时候就要说一说 编码 了。

有关Unicode编码的最初想法是导致前面提到的双字节迷思的元凶,这个想法就是把所有数字存到两个字节里去,所以Hello就变成了:

  1. 00 48 00 65 00 6C 00 6C 00 6F

对吧?先别急着下定论!这也可以表示成:

  1. 48 00 65 00 6C 00 6C 00 6F 00

技术上讲的确如此,我觉得这也是确实可行的,实际上早期Unicode编码实现就是想把Unicode码点分别保存为高端序(High-Endian)模式和低端序(Low-Endian)模式,具体用那种取决于CPU能最快处理的端序模式。

译注:端序(Endian)即CPU选择何种顺序存储多字节的字节排列顺序,高端序就是地址小的存高位,低端序就是地址大的存高位。

所以人们被迫使用了一个古怪的惯例,在每个Unicode字符串的开头存储 FE FF,这两个字节被称为Unicode字节序掩码(Unicode Byte Order Mark)。如果你调换高低位字节的顺序这个掩码就变成了FF FE,这样人们就可以在读取字符串的时候注意到,并把顺序给调换回来。不是所有的Unicode字符串都有字节序掩码。

这样子看起来已经足够好了,但是有人抱怨:“存那么多零干什么!”因为使用英文的美国人很少会用到U+00FF以上的码点。 而且加州的那帮自由主义的嬉皮士想要节约(容我讥笑一下)空间,要是德州人根本不会在意多处理两倍的字节啦。但是加州的软蛋根本不想给字符串的存储加倍,话说回来,狗日的(原文如此)ANSI和DBCS字符集的文档早就遍布全球了,谁又来转换这些呢?难道我吗?基于此原因,有数年的时间几乎所有人都决定忽视Unicode的存在,与此同时事态愈加恶化。

译注:节约原文用词conserve -> conservative(保守派)。

于是乎UTF-8这一绝妙概念横空出世。UTF-8是存储Unicode码点的又一系统,这些魔法数字在内存中使用8比特字节存储。在UTF-8编码下,0到127的每个码点用单字节保存。只有大于等于128的码点使用两个或三个,最大六个字节来保存。
image.png
这就带来了一个很方便的副作用,英语文本在UTF-8和ASCII下看起来完全一致,于是美国佬就不会注意到出了什么差错,而世界上的其他部分则必需跳过一条鸿沟。具体说来,Hello这个单词,也就是U+0048 U+0065 U+006C U+006C U+006F会保存为48 65 6C 6C 6F,而这和ASCII,ANSI还有所有OEM字符集一毛一样!现在如果你勇敢到使用带注音标记的字母或者希腊字母或者克林贡文字,你得把多个字节存储为单个码点,但是美国人永远也不会注意到(UTF-8还有个很棒的属性,它不会截断那些以单个0字节作为null终结符旧式字符串编码)。

我已经告诉你了三种编码Unicode的方式。传统的保存两字节法又称UCS-2(因为它有两字节)或UTF-16(因为他有16比特),而且你还是需要搞清楚它的端序是什么模式。然后是流行的新UTF-8标准,它有着很棒的特性,能和英文文本以及只接受ASCII编码的脑残程序一起工作。

然后其实还有很多很多编码Unicode的方式。有UTF-7的,它和UTF-8很相似,但是保证高位比特永远是0,这样如果你把Unicode传给严格的警察邮件系统(认为7比特就足够)时,这个编码能毫发无损地传递文本。还有叫USC-4的,它用4字节存储每个码点,这样每个码点就能以相同数量的字节保存,但是天杀的,连纯正的德州人也不敢这么浪费内存呀。

而且你实际上是用Unicode的码点来表示理想中的英文字母,而码点也可以用任何一种老派的编码格式进行编码!打个比方,你要用ASCII或OEM希腊文又或者希伯来文ANSI规范来编码Unicode字符串HelloU+0048 U+0065 U+006C U+006C U+006F),这些都可以,但是存在一个缺陷:有些字符无法显示出来!如果编码规范里面没有对应Unicode码点单对等选项,通常来说你就会得到?这个字符,或者放在盒子里的那种:�。

传统编码有上百种之多,这些编码只能显示一部分Unicode码点,其他的码点都会变成问号。有些英文编码格式会比较流行,比如Windows-1252(Windows 9X的西欧语言标准)以及ISO-8859-1,也就是Latin-1(也是任何西欧语言都适用)。
但要是用这些编码存储俄文或者希伯来文,得到的只会是一大堆问号。而UTF-7,8,16和32都可以正确存储任何码点。

有关编码的重中之重

看到这里,你是不是忘干净我刚刚说了什么?但没关系,请记住这极其重要的一点。不知道编码的字符串是没用的字符串。你不能假装一段纯文本用的就是ASCII。

纯文本根本不存在。
如果内存,文件或者电邮中包含一个字符串,你必须要知道它用的是什么编码,否则你就不能正确解析并显示给用户。

“我的网站充满了辊金铐”,“她看不懂我用了注音符号的邮件”,几乎所有这样的问题都是因为有个天真的程序员不理解这个现实:如果你不告诉我你的字符串用的是什么编码,我就不知道怎么去正确显示甚至无法了解文本在哪里终止。世界上有着千百种编码,码点都在127之上,猜对编码如同大海捞针。

那么如何在字符串里保存编码信息呢?做到这一点有很多种标准可以选择,对于电子邮件,你需要在表单的头部附加一段字符串:

  1. Content-Type:text/plain;charset="UTF-8"

对于网页而言,最开始的想法是服务器返回的网页的同时也返回一个和上面类似的Content-Type HTTP头,但是这个响应头需要在HTML页面返回前就要发送。

这就造成了很多问题。假设你的网络服务器上的站点很多,用户上传的页面也很多,这些页面的语言也不同,而且这些页面的编码都是用微软FrontPage生成的。服务器本身根本就不会知道每个文件的编码,所以也就不能发送Content-Type响应头。

要是HTML文件里可以用某个特殊标签去存储Content-Type就好了。当然这样会招致纯净主义者的愤懑…你怎么能在知道HTML编码之前读区HTML文件呢?幸运的事,几乎所有的常用编码在32到127之间的字符都是相同的,所以你可以读取到不使用杂七杂八字符的HTML页面开头部分:

  1. <html>
  2. <head>
  3. <meta http-equiv="Content-Type"
  4. content="text/html; charset=utf-8">

不过这个meta标签必须要在head的部分就被声明,因为一旦浏览器发现了这个标签,它就会停止解析网页,而是重新用你指定的编码去再次解析。

要是浏览器没有发现任何Content-Type怎么办?IE浏览器的做法很有意思:它会先试着基于不同语言中不同文字对应的字节出现频率猜测文件编码。因为各种8比特的代码页倾向于把他们所属国家的字母放在128到255之间,而每种人类语言在使用上都有特定的不同词频,所以IE有几率猜对编码。虽然有些奇怪,但是对于不懂Content-Type头的网页编写者而言通常足够好用了。知道某天他们写下了某些不符合本土语言词频的文字,IE觉得这些是韩文就会直接显示韩文。这恰好证明了Postel定则所说的,“对发送内容保持谨慎,对接收内容保持自由。”其实并不是一个很好的工程原则。不管怎么说,如果一个网站用的保加利亚文,显示的却是韩文(甚至不是流利的韩文),那么网站的读者该怎么办呢?他会打开 视图 | 编码 菜单,在一大堆的编码选项(西欧语言就有十几种编码)里找到能正确显示文本的那个。然而大部分人都不知道他们可以这样做。

在我公司发布的网站管理软件CityDesk的最新版本里,我决定在内部统一使用UCS-2(双字节)Unicode,也就是Visual Basic,COM和WindowsNT/2000/XP的原生自负类型。在C++代码里我们把字符串声明为wschar_t(“wide_char”)而不是用char,我们用wcs函数而不是使用str函数(比如使用wcscatwcslen而不是strcatstrlen)。在C语言里创建UCS-2字面量则需要在字符串前加一个L。比如L"Hello"