# 四、每一位软件开发人员必须、绝对要至少具备UNICODE 与字符集知识(没有任何例外!) 有过这样的经历吗?从保加利亚的朋友那里收到电子邮件,主题却为 “?????????????????”? 我一直十分沮丧地试图去揭示到底有多少软件开发人员没有真正跟上那个充斥着字符 集、编码技术、Unicode及其素材的神秘世界的变化速度。数年以前,FogBUGZ[1]的P测试 人员一直想知道自己是否能够处理日语电子邮件。日语?他们收到过日语电子邮件吗?我不 知道。 当近距离地审视我们一直用来解析Ml ME电子邮件的商业ActiveX控件时,我发现它确实 在字符集方面做得很差。实际上,我们只得用自己写的代码取消它完成的错误转换,然后重 新进行转换工作。在打量另外一个商业软件库时,我们发现它同样有一个毛病很多的字符代 码实现形式。我与那个软件包的开发人员进行了联系,他好像认为“不能去为它做任何事情”。 与许多程序员一样,他仅仅希望它会以某种方式滑过去就行了。 不过,它是不会过去的。我发现流行的Web开发工具PHP,几乎完全忽视了字符编码方面 的问题[2]而单纯地使用8位字符,它不大可能开发出好的国际Web应用程序。我真正感觉到, 够用是怎么一回事情了。 因此,我要做出一项宣示:如果你是一位21世纪的程序员,却不知道字符、字符集、编 码技术与Unicode方面的基本知识,那么要是让我逮着,我打算通过让你在潜艇上剥6个月的 洋葱来惩罚你。我发誓我会这么做。 还有一件事情: 它并不那么难。 在本章中,我将告诉你到底每个真正动手编程的程序员应该知道什么。所有关于“纯文 本=ASC 11=字符是8位”之类的看法不仅是错误的,而且错误得令人绝望。要是你仍然按这样 的方式去编程,那么你比一位不相信细菌的医学博士好不了多少。务请在读完本章之前不要 再写另外一行代码。 在实际开始之前,我得提请你注意,如果你不是罕见的几位知道一些国际化知识的人员 之一,那么你会觉得我整个讨论有点儿过于简单。其实,我不过是在这里试图做最少的一点 抛砖引玉的工作,以便让大家都能够理解后续的知识,并且能够使写出的代码有望在任何语种的文本环境,而不仅仅是不包括重音的英文子集的环境下使用。 我还要提醒大家注意,尽管字符处理仅仅是创建能够在国际语言环境下使用的软件所要 完成的很微小的部分,不过我一次只能写一件事情,因此,今天在这里谈到的就是字符集。 ## 历史情景 理解这个素材最为容易的方式是按年代顺序依次推进。你可能觉得我打算在这里谈论像 EBCDIC之类的古老的字符集。唔,不会的。EBCDIC与你的生活毫不相干。我们也没有必要追 溯那么久远的年代。 在计算机出现后的中期时代,人们开发了UNIX,K&R写出了《C程序设计语言》,一切都 显得非常简单。EBCDIC才刚刚浮出水面。惟一很要紧的字符集就是有效而古老的无重音英语 字母,而人们为这些字符开发了一套称为ASCII的编码,它能够使用32到127之间的数字表示 各个字符[3]。空格的编码是32,字母A的编码是65,等等。这种编码能够很方便地用七个二 进制位来表示。 由于那个时期生产的大多数计算机使用8位大小的字节,因此用户不仅可以存放所有可 能的ASCII字符,而且有整整一位空余下来。如果你技艺高超,可以将该位用做自己离奇的 目的:WordSta「中那个发暗的灯泡实际上设置这个高位,以指示一个单词中的最后一个字母, 同时这也宣示了 WordSta「只能用于英语文本。码值小于32的代码称为非打印字符而作为杂项 字符使用,也就是当做小不点儿来用。这些字符用做控制字符,比如,码值为7的字符使计 算机发出“嘟嘟”声,而12对应的字符使当前走一页打印纸并换入新的一页。 对于一个说英语的人而言,这一切都是不错的。 由于字节有多达8位的空间,因此许多人在想:“呀!我们可以把128~255之间的编码用 做个人的应用目的。”问题在于,同时产生这种想法的人相当多,而且在128~255之间的各 个位置上应该存放什么这一问题上,真是仁者见仁智者见智。IBM-PC有一种OEM字符集,它 提供了一些欧洲语系的重音字符,以及一组画线字符一一水平线条、竖直线条与右边有弯折 的水平线条等。你可以使用这些画线字符在屏幕上画出好看的方框与线条,只要环境清洁干 燥,绘制的图形在8088计算机上仍然看得见。 事实上,只要人们开始在美国以外的地方购买计算机,那么各种各样的不同OEM字符集 都会进入规划设计行列,并且各人都会根据自己的需要使用高位的128个字符。比如,在一 些PC机器上编码为130的字符显示为而在以色列销售的计算机上它显示为希伯来语的第三 个字母A。因此,当美国人想把自己的履历(「gsum6s)发给以色列时,显示出来的会是r 入sum AS。在使用诸如俄语之类的语种的多种情况下,对高端128个字符可能存在很多不同 的处理思路。如此一来,甚至在同语种的文档之间就不容易实现互换。 最后,这个人人参与的OEM终于以ANSI标准的形式形成文件。在ANSI标准中,每个人都 认同如何使用低端的128个编码,这与ASCII相当一致。不过,根据所在国籍的不同,处理编 码128以上的字符有许多不同的方式。这些不同的系统称为代码页[4]。这样一来,比如说在 以色列DOS使用称为862的代码页,而希腊用户使用的代码页是737。这两个代码页在128以下 是一样的,但在128以上则不相同,该代码段存放的是一些古怪的字母。MS-DOS的国家版本 有几十个这样的代码页,负责将所有的英语信息转换成冰岛语。它们甚至还拥有几个“使用 多语种”的代码页,从而可以在同一台计算机上处理世界语与加利西亚语!呜哇!不过,要 说一下,在同一台计算机上处理希伯来语与希腊语是完全不可能的事情,除非自己编写一个 定制程序以显示使用位映象图形的任何内容,因为希伯来语与希腊语需要能够对高端编码做 出不同解释的不同代码页。 同时,甚至更为令人头疼的事情正在逐步上演,亚洲国家的字符表有成千上万个字符, 这样的字符表是用8位二进制无法表示的。该问题的解决通常有赖于称为DBCS(double byte character set,双字节字符集)的繁杂字符系统。这个字符系统将一些字符存储为一个字 节,而让另外一些字符占据两个字节。虽然在字符串中向前移动很容易,但向后移动几乎不 可能做得很棒。我们不鼓励程序员使用s++与s–的代码形式在字符串中前后移动,而是提 倡调用Windows的AnsiNext与AnsiPrev函数,这两个函数知道如何去应付所有的繁杂局面。 不过,仍然需要指出一点,多数人还是姑且认为一个字节就是一个字符,以及一个字符 就是8个二进制位,并且只要确保不将字符串从一台计算机移植到另一台计算机,或者说一 种以上的语言,那么这几乎总是可以凑合。当然,只要一进入Internet从一台计算机向另一台计算机移植字符串就成为家常便饭了,而各种复杂状况也随之呈现出来。令人欣慰的是,Unicode随即问世了。 ## Unicode Unicode勇往直前地创建一种单一字符集,试图囊括地球上所有合理文字体系,以及诸 如一些Klingon之类的人为书写体制。一些人错误地认为,Unicode就是一种每个字符占用16 个二进制位,从而总共可以表示65 536个可能的字符的16位字符编码方案。这并不十分正确。 它是关于Unicode的惟一最为流行的神话,因此,要是你也这样想,大可不必感到难过。 事实上,Unicode在考虑字符方面给出了一种不同的思路。你需要去理解它的这种思考 方式,否则一切都显得毫无意义。 直到现在,我们一直认定字母映射为磁盘或者内存的某些位: ``` A -> 0100 0001 ``` 在Unicode中,一个字母映射为一种称做代码点(code point)的对象,它仍然只是一 个理论上的概念。该代码点如何在磁盘或者内存中进行表示完全是一种微妙的个体行为。 在Unicode中,字母A是一个柏拉图式的理想,它只能飘荡在天国之中: ``` A ``` 这个柏拉图式的A不同于B,也不同于a,但与A或者或者A却是一样的。认为Times New Roman字体的A与Helvetica字体中的是相同的字符,但不同于小写字母a的想法,看起来并 不会引起很大的争议,但在某些语言中仅仅指出字母是什么完全可能引起争议。德语的字母 6是真正的字母,抑或仅仅是ss的一种花样书写形式?如果一个字母的形状在单词的末尾变 化了,那么它是一个不同的字母吗?希伯来语系认为是,而阿拉伯语系则说不。不管怎样, 聪明的Unicode人在最近大约十年里一直在设法处理这个方面的问题,与此相伴的是大量高 度政治化的争论。不过,你用不着担心,他们己经把它全部处理妥当了。 Unicode组织为各种字母表中每个理想化的字母分配一个神奇的编号,其书写形式为 U+0645。这个神奇的编号称为一个代码点。U+的含义是“Unicode”,这些数字是十六进制 的。U+0639表疋阿拉伯字母Ain。英语字母A是U+0041。使用Windows 2000/XP的charmap工具 程序或者访问Un i code网站,可以找到所有的字母及其编码[5]。 Unicode能够定义的字母个数其实没有限制,实际用到的总字符个数己经远远超出了65 536的范围。可见,并不是每个Uni code字母能够真正挤压成两个字节,不过,这终归还是一 个神话。 好了,我们来看一个字符串: ``` Hello ``` 该字符串在Uni code中对应如下形式的5个代码点: ``` U+0048 U+0065 U+006C U+006C U+006F ``` 这不过是一簇代码点。其实,就是一些编号。到此为止,我们还没有谈到如何在内存中 存放这类字符串或者怎样在电子邮件中表示它们。 [1]这是我们的故障跟踪产品,见www.fogcreek.com/FogBUGZ。 [2]见ca3.php.net/manual/en/language.types.string.php。 [3]关于ASCII字符的更多信息,参见 www.robelle.com/library/smugbook/ascii.htmlo [4]关于代码页的更多信息,参见 www.i18nguy.com/unicode/codepages.html#msftdos〇 [5]见www.unicode.org。 ## 编码 是给出编码知识的时候了。 关于Unicode编码,最为容易同时也因此导致2字节神话出现的想法是:“嗨,让我们将 这些编号各自用两个字节存放吧!”于是,”Hello”变成了 `00 48 00 65 00 6C 00 6C 00 6F` 对吧?不要这么性急!可以存为 `48 00 65 00 6C 00 6C 00 6F 00` 吗? 喏,从技术上讲是可以的,我就相信这一点。事实上,早期的实施人员就想以高端或者 低端两种存放模式在内存中表示Unicode码。不管特定的计算机在何种模式下运行最快,喔, 也不管是傍晚还是清晨,现在己经有了两种存放Unicode码的方式。于是,人们被迫提出在 每个Unicode字符串的开头存放一个FEFF标记的怪异协定。这个标记称为Unicode字节顺序 标记(Unicode Byte Order Mark)。要是交换了它的高低位字节,该标记就成为FF FE。这 样一来,阅读字符串的人就知道必须将字符串的每两个字节进行一次交换[1]。吁!苍茫世 界当中不是每个Unicode字符串都会在开头放一个字节标记。 曾几何时,这样的做法好像是足够好了。但是,程序员却一直没有停止抱怨:“瞧瞧所 有那些零!”要知道,作为美国人,他们阅读的英语文本很少用到U+00FF以上的代码点。 还有,他们是来自加利福尼亚州的无拘无束的嬉皮士,保守与冷嘲热讽是他们的天性。如果 他们是得克萨斯人,就不会在乎要狂吃双份的字节。不过,那些咕咕叽叽的加利福尼亚人不 会忍受将字符串占据的存储空间加倍的想法。而且,不管怎么说,己经存在的一些该死的文 档都赫然使用着各种各样的ANSI与DBCS字符集,谁会去将它们全部转换过来呢?仅仅是这个 原因,大多数人决心在几年之内对Unicode视而不见。不过,与此同时,情况却变得尤为糟 糕。 于是乎,光彩照人的UTF-8[2]概念就横空出世了。UTF-8是另外一种存放字符串的 Unicode代码点的体系,它在内存中使用8位字节存放那些神奇的U+编号[3]。在UTF-8中,从 0~127的每个代码点存放在单个字节里面,只是128以上的代码点才使用2个字节、3个字节, 乃至多达6个字节的空间来进行存储。 | 最小十六进制 | 最大十六进制 | 二进制字节序列 | | --- | --- | --- | | 00000000 | 0000007F | 0vvvvvvv | | 00000080 | 000007FF | 110vvvvv 10vvvvvv | | 00000800 | 0000FFFF | 1110vvvv 10vvvvvv 10vvvvvv | | 00010000 | 001FFFFF | 11110vvv 10vvvvvv 10vvvvvv 10vvvvvv | | 00200000 | 03FFFFFF | 111110vv 10vvvvvv 10vvvvvv 10vvvvvv 10vvvvvv | 这具有使英语文本看起来在UTF-8与ASCII中显得完全一样的边界净化效应。如此一来, 美国人用不着在意有什么地方不对了。只是世界上其他地方的人得从铁箍中跳过去。具体地 说,编码为 U+0048 U+0065 U+006C U+006C U+006F 的 “Hello〃字符串将存储为48 65 6C 6C 6F。瞧!这与地球上存储为ASCII、ANSI与各种OEM字符集的形式完全相同。 现在,如果你非得固执地使用重音字母或者希腊字母或者Klingon字母,那么就必须使 用几个字节来存放单个代码点,但是美国人永远不会意识到有什么异样。(UTF-8同样具有 后面这种不错的属性:现有的那种想用单个0字节作为空终止字符的陈旧而愚昧的字符串处 理程序,不会截掉字符串。) 到此为止,我己经说出了Unicode的三种编码方式。将编码存储为2个字节的传统方法称 为UCS-2 (因为它有2个字节)或者UTF-16 (因为它有16位),不过仍然需要弄清它是高端 存放模式的UCS-2,还是低端存放模式的UCS-2。还有一种流行的新UTF-8标准,它同样可以 很自然地使用,要是你碰巧处理令人满意的英语文本,并拥有完全不知道世间除了 ASCII之 外还有其他何物的死脑筋程序的话[4]。 其实,还有一些其他的Unicode编码方式。一种称之为UTF-7的编码体系与UTF-8有许多 相似之处,只不过它保证高位总是为0。这样一来,要是你必须通过某类极权国家的严密电 子邮件系统传递Unicode,它可以在高压之下仍然毫发无损。这种电子邮件系统认为用7位二 进制表示一个字符完全够用了,谢谢。另外一种称为UCS-4的编码方式用4个字节存放一个代 码点,它虽然具有这样一种良好性能:将各个代码点都存储为个数相同的字节,不过,天哪! 即使得克萨斯人也不会鲁莽到浪费那么多的内存。 事实上,既然以Unicode代码点形式的柏拉图理想字母来思考问题,Unicode代码点也就 能够按任何守旧的编码方案进行编码了!比如,可以用ASCII编码方案、老式OEM希腊编码 方案、希伯来ANSI编码方案或者几百种现有编码方案之一来编码Unicode字符串“Hello” (U+0048 U+0065 U+006C U+006C U+006F),只是要抓住一点:一些字母可能是不出现的! 如果在试图使用的编码方案中没有相应Unicode代码点的等价内容,那么通常会显示一个小 问号“?〃,或者更为先进一些的话,就显示一个方框。 传统编码方式不下几百种,不过它们仅仅能够正确地存放一些代码点,将所有其他的代 码点转变为问号。流行的英语文本编码方案有Windows-1252 (西欧语种的Windows 9x标准) 与ISO-8859-1,即Latin-1 (同样对任何西欧语种都有用)[5]。不过,如果试图用这类编码 方式存放俄语或者希伯来语字母,那么得到的结果就会是一簇问号。UTF 7, 8, 16与32都具 有能够正确存放任何代码点的优良特性。 [1]关于字节顺序标记的更多信息,参见 msdn.microsoft.com/library/default.asp?url=/library/en-us/intl/unicode_42jv.asp。 [2]见 www.cl.cam.ac.uk/~mgk25/ucs/utf-8-history.txt 。 [3]关于UTF-8的更多信息,参见 www.utf-8.com/ 。 [4]见 www.zvon.org/tmRFC/RFC2279/Output/chapter2.html 。 [5]关于 ISO 8859-1 字符集的概况,参见 www.htmlhelp.com/reference/charset 。 ## 编码中惟一最重要的事实 即使你将我刚才所说的一切忘得一干二净,我也要请你记住一个极其重要的事实。有一 个字符串,而不知道它所使用的编码方案是毫无意义的。你再也不用将脑袋紧贴在沙地上而 假想“纯”文本就是ASCII。 ## 纯文本不是这么一回事 如果在内存、文件或者电子邮件中有一个字符串,那么应该知道它使用的是什么编码方 案,否则就不能将它正确地解释或者显示给用户。 其实,几乎所有诸如“Web网页好像是乱码”或者“在使用重音字母时无法阅读电子邮 件”之类的愚蠢问题,都可以归结到天真的程序员身上,他不了解这样一个简单的事实,即 如果不告诉我某个特定字符串是用UTF-8,或者ASCII,或者ISO 8859-1 (Latin 1),或者 Windows 1252 (西欧)编码的,那么我就不能正确地显示,乃至弄清楚它在什么地方结束。 编码方案数以百计,而在代码点127以上,拍脑袋己经没有效果了。 如何保存字符串用到的编码方案信息?喏,做这件事情有许多标准的方式。对于电子邮 件信息,需要在表窗体标题放一个字符串 ``` Content-Type: text/plain; charset="UTF-8" ``` 对于Web页,最初的想法是Web服务器通过网页本身返回一个类似于Content-Type的http ——不是在HTML当中而是作为一个响应标题在HTML页面之前返回。 这会引起一些问题。假设一个大型Web服务器拥有许多站点,成千上万的网页是由许多 人使用多种不同语言发布的,网页使用任何在Microsoft FrontPage看来很适合产生的编码 方案。由于Web服务器实际上不知道每个文件究竟是用什么编码方案写成的,因此它不能发 送 Content- Type 标题。 如果能够使用某种特殊标记将HTML文件的Content-Type内容自然地放在HTML文件当中, 会是很方便的。当然,这会让纯化论者发疯。不过,在知道HTML文件使用了什么编码方案之 前,如何读取HTML文件呢?!幸运的是,几乎每个用得很普遍的编码方案,对码值32与127 之间的字符都以相同方式进行处理。因此,人们总是可以充分展示HTML页而不必使用一些很 稀奇的字母: ``` <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> ``` 不过,这里的meta标记确实位于段中很_*_前的位置。因为只要Web浏览器看到该标记,它就会停止解析页面,并在使用指定的编码方案重新解释整个页面之后进入下一轮。 如果Web浏览器在http报头与meta标记中都找不到任何Content-Type,该怎么办呢? Internet Explore「确实做了件非常有趣的事情:基于不同语言以典型编码方案得到的典型 文本中各种字节出现的频率,试图猜出使用了什么语言与编码方案。由于各类不同的旧8字 节代码页倾向于将其母语字母放在128与255之间的不同范围中,并且人类使用的每个语种具 有不同特征的字母使用概率分布,因此使这种做法确实有了发挥作用的机会。是的,它确实 显得有点怪异。不过,这种做法通常能够满足那些从不知道要使用Content-Type报头的幼稚 网页编写人员,在Web浏览器中阅读网页的需要,并且看起来还很不错。 直到有一天,编写的网页确实与母语的字母使用概率分布情形不一致,Internet Explore「则认定它是朝鲜语而加以显示。在我看来,这表明了一点,Jon Postel的格言“宽 进窄出”实在不是一条好的工程原则[1]。不管怎么说,一旦用保加利亚语写的网站却以朝 鲜语的形式出现(甚至是毫不相干的朝鲜文),可怜的读者该怎么办呢?使用菜单“View | Encoding (视图|编码)”试图应用一组不同的编码方案(东欧语种可用的编码方案至少有 一打),直到图片能够较为清楚地显示出来为止。即使这位仁兄知道如此行事,可大多数人 却不知道这一招。 对于由本人的公司Fog Creek Software发布的Web站点管理软件最新版本CityDesk[2] 而言,我们决定在内部用UCS-2 (2字节)Unicode处理一切。这种编码形式也是VisualBasic、 COM与Windows NT/2000/XP作为其固有字符串类型使用的。在C++代码中,字符串就定义为 wchar_t而不是char,并且使用以wcs打头的函数而不是以st「打头的函数(比如说,使用 wcscat与wcslen,而不是strcat与strlen)。要用C代码生成UCS-2文本字符串,就得像 L”Hello”—样在字符串前面放一个L。 CityDesk发布Web页时,将网页转换为UTF-8编码形式,该编码方案多年来一直得到Web 浏览器的良好支持。“Joel on Software ”(《Joel说软件》)的29种语言版本全部使用这 种方式编码,我迄今为止还没有收到一个人的来信说他在浏览网页内容时遇到任何麻烦[3]。 本章写得有点长,并且未能覆盖需要了解的一切字符编码与Unicode内容。但我还是希望你好好地读一读,它对于你回过头来看编程有一定好处,正如用抗生素,而不是猛药与符 咒。这就是我现在留给你去做的一项任务。 [1]Jon Postel的话,引自信息科学学院的1981年9月发布的文档“RFC 791 - Internet Protocol” 。 [2]见 www.fogcreek.com/CityDesk 。 [3] www.Joelonsoftware.com/navLinks/OtherLanguages.html 。