每个软体开发者都绝对一定要会的Unicode及字元集必备知识(没有借口!)

作者:周思博(Joel Spolsky)
译:Paul May 梅普华
Wednesday, October 08, 2003
属于Joel on Software, http://www.joelonsoftware.com

还搞不懂那个神秘的Content-Type tag吗?你知道的,就是那个应该放在HTML里却又永远不知道该设成什么内容的标签啊。

你曾经收到在保加利亚的朋友寄来,主题是「???? ?????? ??? ????」的电子邮件吗?

ibm.jpg

很多软体开发者并未真正完全理解字元集、字元编码、Unicode等等的神秘世界,当我发现不懂的人那么多时真的很失望。数年以前,某位beta测试人员想知道FogBUGZ是否能处理日文的电子邮件?他们竟然用日文写电邮?我完全不知道耶。我们用了一个商用ActiveX控制元件来分析MIME电邮讯息,当我仔细调查这个元件时,才发现它对字元集的处理完全错误,所以我们还写了些了不起的程式,把错误的转换还原后再重做正确的转换。我又去看看另一个商用程式库,它的字元编码实作也是完全不对。我联络该软体的开发者,结果他似乎有点认为没办法改善。他跟很多程式师一样,只希望这个问题能凭空消失。

不过问题并不会消失。PHP是个很普遍的web开发工具,不过它完全忽略字元编码问题,PHP很愉快地用8位元来处理字元,因此几乎不可能开发好的国际化web应用程式。当我发现这件事时,觉得 真是够了

所以我要做一个宣告:如果你在2003年还是个程式师,而你不知道字元、字元集、字元编码、以及Unicode的基本知识,我就要去_抓_ 你,我会让你在潜艇里关6个月剥洋葱。我发誓我一定会的。

另外还有一件事:

这并没有那么难。

我会在这篇文章中让你确实了解_每个现役程式师_ 都应该知道的事情。所谓「纯文字= ascii = 字元都是8个位元」的说法不仅不对,而且还错得离谱;如果你还是照这个想法写程式,那么你大概不会比不相信细菌的医生好多少。在读完这篇文章之前请暂时不要写程式。

在我开始之前应该先提醒一下,如果你是极少数了解多国语言软体制作的人,会发现我的讨论有点过度简化。我只是想设立一个底线,让大家能了解这是怎么一回事,而且写出的程式有 希望 能处理任何语言的文字,而不是只认得没有重音符号的英文。另外我也要提醒你,字元处理只占建立多国语言软体的一小部份,不过我一次只能写一件事,所以今天只谈字元集。

历史的观点

要了解这些事,最简单的方法就是按年代来看。

你或许会认为我会讲些EBCDIC之类很古老的字元集。我不会,EBCDIC跟你的生活无关。我们并不用回溯到那么前面。

ascii.png

回到没那么古老的从前,Unix被发明而K&R正在写[The C Programming Language](http://cm.bell- labs.com/cm/cs/cbook/)的那个时代,当时每件事都非常简单。EBCDIC正在被淘汰。唯一重要的字元集就是古老美好的无重音英文字母,我们有一个对应的编码系统叫做ASCII,可以用32到127的数字表示每一个字元。空白是32,字母A是65,如此类推。这种方法可以把文字存成7个位元。当时大部份电脑的一个位元组都是8个位元,所以储存全部ASCII字元之后有很多个位元没用到。如果你够邪恶,就会偷用这些空位元:事实上WordStar的坏蛋就用把最高位元设起来,代表一个单字中的最后一个字母。这是开玩笑的。空的位元被用来当控制字元,比如7会让你的电脑发出哔声,而12会让印表机把目前正在印的纸张送出并且卷入一张新纸。

所以天下太平,不过只限于英语系的人。

oem.png

由于位元组有8个位元的空间,所以很多人就开始想啦:「对了,我们可以把128到255的码拿来自己用。」问题是_很多_ 人同时都有这个想法,所以128到255的空间该怎么用,大家都各自有自己的想法。IBM- PC用了一种名为OEM字元集的东西,提供了某些欧洲语言用的重音字母和[一堆线条绘图字元](http://www.jimprice.com/ascii- dos.gif):水平线、垂直线、右边有个小吊钓的水平线等等。你可以用这些线条绘图字元在萤幕上拼出很漂亮的方框和线条,在干洗店里的8088电脑还可以看到这种图案。事实上当PC开始卖到美国以外时,各种不同的OEM字元就被凭空创造出来,大家都把上面这128个字元拿来自己用。举例来说,字元码130在某些PC上会显示为é,不过在以色列卖的电脑上就变成希伯来文字母Gimel ( gimel.png ),所以当美国人把履历(résumé)寄到以色列就会变成r gimel.png sum gimel.png 。在很多情况下,比如说俄文好了,本身对于上面128个字元(值>127)就有很多不同的想法,所以甚至连俄文文件本身都无法可靠地互换。

后来这段OEM乱用区终于在ANSI标准里固定下来。在ANSI标准中,大家都同意小于128的字元定义(基本上和ASCII一致),不过由128开始的字元就有很多不同的处理方法,会依照你住的地方而定。这些不同的系统就叫做 页码(code page) 。举例来说以色列的DOS用叫862的页码,而希腊用户则是用737。它们在128以下是一样的,不过由128起就不同了,里面充满奇奇怪怪的字母。美国版本的MS- DOS有几十种页码,由英文到冰岛文都可以处理,甚至还有一些「多语」页码可以_在同一台电脑_ 上处理世界语和加利西亚语!了不起!不过要一台电脑同时处理希伯来文和希腊文是绝对不可能的,除非你自己写程式自己用图显示所有文字。因为希伯来文和希腊文对128以上字元的解释方法不同,必须用到不同的页码。

在同一时期亚洲发生的事情更夸张。由于亚洲的字母系统有几千个字母,不可能用8个位元表示。通常是用一种叫DBCS的麻烦系统来处理。DBCS是双位元组字元集(Double Byte Character Set),字元集中的_某些_ 字母是一个位元组来存,其他字则要用两个位元组。在DBCS的字串中要向后移到下一个字很容易,不过几乎不可能往回移到前一个字。程式师被指示向后及往回移时不能用s++和s–,而是呼叫Windows的AnsiNext和AnsiPrev之类的函数,只有这些函数才知道怎么处理这些麻烦。

不过大多数人还是假装一个位元组就是一个字元,而一个字元就是8个位元。只要不会把字串在电脑间移动,或者只用一种语言,这种想法大致上还是能用。不过当Internet兴起,在电脑间移动字串变成随时都在做的事,整团麻烦自然就爆出来了。还好这时已经发明了Unicode。

Unicode

Unicode是个勇敢的尝试,想用单一个字元集去涵括地球上所有合理的书写系统,另外也要包括克林贡语等杜撰的语文。有些人误认为Unicode只是个16位元码,里头每个字都要占16位元,所以总共有65,536个字元。 事实上这并不正确。 这是关于Unicode常见的误解,所以如果你也这么认为的话,不用难过。

事实上Unicode对字元有不一样的想法,你必须了解Unicode的想法,否则是搞不懂的。

到目前为止,我们都假设一个字母会对映到某些位元,这些可以存在磁碟或记忆体中:

A -> 0100 0001

在Unicode里一个字母是对映到一个叫_code point_ 的东西(还只是一个理论上的概念)。要如何在记忆体或是磁碟上表示code point就完全是另一回事。

在Unicode中,字母A是个精神上的观念。它只会漂浮在天堂里:

A

这个观念上的A和B或者a都不一样,不过A和_ A_ 以及A都一样。Times New Roman字型的A和Helvetica字型的A是相同的字元,但和小写的"a" 不一样 ,这种想法似乎没什么好争论的。不过在某些语言中,光是要决定一个字母 什么就有得吵了。举例来说,德文字母β究竟真正的字母还是ss(译注:拉丁文的gei)的另一种特别写法呢?如果字母的形状在单字结束时会改变,改变之后要当作不同的字母吗?希伯来文说是,阿拉伯文却认为不是。不管如何,Unicode协会的聪明人已经在过去十年左右搞定了,虽然有一大堆政治争论伴随而来,不过你不用担心。他们已经完全搞定了。 Unicode协会把所有字母系统中每一个观念上的字母都分配一个魔术数字,这个数字的写起来就像是: U+0645 。这个魔术数字就叫一个_code point_ 。U+的意思是Unicode,数字则是用十六进位表示。U+FEC9 就是阿拉拍文的字母Ain。英文字母A则是U+0041 。你可以用Windows 2000/XP的charmap 工具把这些数字全找出来,到Unicode网站也可以找到。

Unicode可以定义的字母数量并没有实质限制,事实上可以超过65,536个,所以并不是所有的Unicode字母都能挤进两个位元组里,不过反正那本来就是个迷思。

好吧,假设我们有个字串:

Hello

用Unicode来表示的话,这个字串会对映到下面五个code point:

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

就只是一堆code point。实际上也就是数字。不过我们还没有提过要如何储存到记忆体或在电邮讯息中表示。

字元编码

这就是_字元编码_ 上场的地方。

Unicode编码最初的想法导致了两个位元组的迷思,简单说就是把那些数字都存成两个位元组。所以Hello变成

00 48 00 65 00 6C 00 6C 00 6F

这样对吗?等一下!也有可能会是:

48 00 65 00 6C 00 6C 00 6F 00 ?

好吧,技术上是的,我的确相信可以这样写,而事实上早期的实作者希望能把Unicode码存成high-endian或low- endian模式,可以依据CPU用哪一种最快来决定。于是就有_两_ 种储存Unicode的方法。所以人们被迫想出奇怪的作法,在每个Unicode字串的开头存一个FE FF;称之为[Unicode Byte Order Mark](http://msdn.microsoft.com/library/default.asp?url=/library/en- us/intl/unicode_42jv.asp)。如果你把高低位元组对调,标记就会变成FF FE,读字串的人就知道其他位元组都要对调。不过外面的Unicode字串开头并不一定都会有这种位元组顺序标记。

hummers.jpg

有一段时期这个方法好像还不错,不过后来有程式师在抱怨了。他们说:「看看那些零」,因为他们都是美国人,看到的都是很少用到U+00FF以上code point的英文文字。何况他们还是注重_保育(哼)_ 又崇尚自由的加州嬉皮。如果他们是德州佬,才不会在意要花掉两倍的位元组呢(译注:指德州人少地方大,所以财大气粗)。不过那些加州糊涂蛋受不了字串储存空间会 倍增 的想法,而且外头已经有太多文件是用各种ANSI和DBCS字元集写的,要找谁来转换这些文件?新闻局吗? 光是这个理由,就让大多数人就决定不管Unicode,几年下来情况就变得愈来愈糟了。

然后就有人发明了[UTF-8](http://www.utf-8 .com/)这个绝佳的点子。UTF-8是另一个储存系统,用8位元方式把Unicode code point(就是那些神秘的U+数字)存在记忆体中。在UTF-8中,由0-127的code point都存成_一个位元组_ 。只有128和更大的code point会存成2或3或个位元组,事实上最多可以用到6个位元组。

utf8.png

这样做有个很巧妙的副作用,就是英文文字_用UTF-8和用ASCII会完全一样_ ,所以美国人根本不会觉得有啥不对。只剩世界上其他地方的人得跳火圈。具体来说Unicode为U+0048 U+0065 U+006C U+006C U+006F的Hello 的,会被存成48 65 6C 6C 6F。看吧!这跟存成ASCII或ANSI或是地球上每一种OEM字元集的结果都一样。这样子一来,如果你斗胆敢用重音字母或希腊字母或是克林贡字母,就得用多个位元组来储存一个code point,只是美国人永远不会发现。(UTF-8还有一个蛮好的特性。由于旧的字串处理程式并不知道Unicode,在处理字串时会用一个值为零的位元组作为字串结尾。如果用UTF-8的话,这些旧程式不会中途截断字串。)

到目前为止我说了_三_ 种Unicode编码的方法。全部都存成两个位元组的传统作法叫做UCS-2(因为用两个位元组)或是UTF-16(因为有16位元),不过你还是得分辨是high- endian UCS-2还是low-endian UCS-2。再来是普遍使用的新UTF-8标准,这个标准有良好的特性,在用只认识ASCII的旧程式处理用英文文字还是一切正常。

Unicode其实还有其他多种编译方式。其中之一叫UTF-7,非常像UTF-8不过保证最高位元一定是零,所以即使经过某个认为7位元_很足够_ 的严苛警察国家电邮系统,还是能亳发无伤地通过。另外也有每个code point都存成4个位元组的UCS-4,好处是每个code point都容量都一样,不过连德州佬都不敢浪费_那么多_ 记忆体。

实际上现在你正在以概念性的字母(表示成Unicode code point)来思考事情,这些Unicode code point也可以用任何老式的编码方法来编码!举例来说,你可以把Unicode字串Hello(U+0048 U+0065 U+006C U+006C U+006F)编码成ASCII或旧的OEM希腊编码,也可以编成希伯来ANSI编码或是到目前为止已发明的数百种编码方式,不过_有一个陷阱_ :某些字母可能会画不出来!如果某个Unicode code point在你所用的编码方式中没有对应的字元,通常就会看到一个小问号?或是一个小方框。在箭头后面你看到的是什么呢?-> 啮(译注:这个字是十六进位EFBF,用UTF-8好像是没有字,在大五码里就是「啮」,因为译文用big5编码,所以看到的是「啮」)?

多达数百种的传统编码方式都只能正确储存_部份_ 的code point,而其他code point则是全部变成问号。常见的英文文字编码有Windows-1252 (Windows 9x对西欧语言的标准)和ISO-8859-1又名Latin-1(也是用于西欧语言),不过想要用这些编码方式储存俄文或希伯来文时就会得到一大堆的问号。而UTF 7, 8, 16和32通通都能正确的储存_任何一个_ code point。

关于字元编码最重要的一个事实

如果你完全不记得我刚说的东西,请至少记住一件超级重要的事实。光有字串却不知道编码方式是不行的 。你不能再把头埋在沙里假装「纯」文字就是ASCII。

根本就没有纯文字这种东西。

假设你有一个字串,不管是在记忆体或在档案还是在电邮讯息里,你都必须知道字串用的编码方式,才能正确解译出来并呈现给使用者。

「我的网站都是乱码」或「她看不到我用重音符号写的电邮」之类的笨问题,几乎全部都是因为某位天真的程式师不了解一个单纯的事实:如果不知道某个字串的编码方式是UTF-8还是ASCII还是ISO 8859-1 (Latin 1)还是Windows 1252 (西欧),根本不可能正确显示出来,甚至连在哪结束可能都找不到。大于127的code point有上百种编码方式,连猜都猜不到。

我们要如何保存某个字串的编码资讯呢?好吧,是有一些标准方法可以用。以电子邮件来说,邮件表头应该会有一个字串:

Content-Type: text/plain; charset=“UTF-8”

如果是网页的话,最原始的想法是在网页之外,再让web伺服器传回一个类似的Content-Type http header。不是放在HTML里面,而是在传HTML网页之前先送的header。

这样做会有问题。假设你有一个很大的web伺服器,很多使用各种语言的人在里面放了很多网站和网页,所有网页的编码方式都是由微软FrontPage自动产生。Web伺服器本身其实并不 知道 各个档案的编码方式,所以也没法子传出正确的Content-Type header。

利用某些特别的tag把HTML档案的Content-Type放在HTML档案里比较方便。当然这会让纯粹主义者抓狂…你怎么能在不知道编码方式之前_读_ HTML档案呢!?幸运的是,几乎所有编码方式由32到127的字元都是一样的,所以不需用到怪字母就能在HTML网页取到这些资讯:

**

不过这个meta tag一定得放在段落非常前面的地方。因为网页浏览器一看到这个tag就会停止分析,然后改用你指定的编码方式重新解译整个网页。

如果浏览器在http header或meta tag都找不到Content-Type时会怎么做呢?Internet Explorer会做一件很有趣的事:它会依据各位元组在各种常见语言编码中出现的频率,猜测网页所用的语言及编码方式。由于各种旧的8位元页码通常把该国的字母放在128到255范围内不同的位置,而各种人类语言的字母使用频率都有不同的分布特性,所以这种做法的确有机会成功。这种做法真的很奇怪,不过似乎的确很有效。效果好到那些天真到不知道要用Content- Type header的网页制作者根本不知道自己错了,因为他们的网页用浏览器来看时_一切正常_ 。等到某一天,当他们写的内容不符合所用语言的字母频率分布时,Internet Explorer就会把它认成韩文来显示。我认为这也证明Postel’s Law中关于「发送时严谨,接收时宽松」的论点实在不是一个良好的工程原则。不管如何,当遇到这个用保加利亚文写却显示成韩文(还不是有意义的韩文)的网页时,可怜的读者要怎么办呢?他会用由选单选 检视|编码,然后尝试各种不同的编码(里面有十几种东欧语言)直到看起来对为止。不过当然是要他会这招才行,不过大多数人都不会。

rose.jpg

我们公司有出一套网站管理软体CityDesk,从上一版起我们决定内部全部使用UCS-2(2个位元组)的Unicode,它也是Visual Basic、COM、以及Windows NT/2000/XP的标准字串型别。写C++程式时只要在字串宣告时用wchar_t (“wide char”)代替char ,再用wcs 函数代替str 函数(比如用wcscatwcslen 代替 strcatstrlen )即可。要在C程式里建立一个UCS-2字串常数,只要在字串前面加个L就好了,就是这样: L"Hello".

当CityDesk发行网页时会把网页转成多年来广受浏览器支援的UTF-8编码。这也是_Joel on Software_ 上29种语言版本编辑的方式,而且还没有人跟我抱怨过有问题。

这篇文章写到这里已经很长了,反正我也不可能写完所有关于字元编码和Unicode的事情。不过我想你既然都读到这里了,应该也学够了可以回去写程式,这次别再用水螅和咒语了,改用现代的抗生素吧。这就是我留给你的工作。

这些网页的内容为表达个人意见。
All contents Copyright © 1999-2006 by Joel Spolsky. All Rights Reserved.