每个软件开发者必须绝对至少需要了解的Unicode和Character Sets的知识(没有借口!)
原文:http://www.joelonsoftware.com/articles/Unicode.html
by Joel Spolsky
译windam
2003.10.8 星期三
你是否曾经对那个神秘的Content-Type标记感到不解?
译注:每个HTML页面的head块中都可能包含一个Content-Type标记,例如:
<meta http-equiv=”Content-Type” content=”text/html; charset=UTF-8″ />
你知道这东西应该被放到HTML里,但是你从来都没有确切得弄清楚它到底应该是什么?
你是否曾经收到过你朋友从Bulgaria发来的Email,它的主题行是“???? ?????? ??? ????”?
当我发现还有那么多的软件开发者并没有真正领会关于字符集(Character sets),编码(encoding),Unicode以及相关知识的时候,我非常失望。几年前,FogBUGZ的一个beta测试者对于它是否能处理收到的日语邮件感到疑惑。日语?他们有日文的Email?我不知道。但当我仔细研究我们用来解析MIME email的商业ActiveX控件时,我们发现它恰恰正好对字符集做了完全错误的处理,于 是,为了撤销控件中所做的错误转换,并正确的重新处理,我们不得不编写修正它的补救代码。而当我研究另一个商业库的时候,发现它有一样的完全错误的字符代 码实现。我和那个代码库的开发者通信,发现他的想法是,他们“不能(对字符集)做任何事(正确的处理)”。就像很多程序员一样,他只是祈祷着,这一切麻烦 事都可以被吹走。
但事实上不会!当我发现流行的web开发语言PHP几乎完全的忽略了字符编码的问题,没心没肺的用了8bit字符,这种傻逼的行为让开发好的国际化web应用变得几乎不可能的时候,我想,我受够了。
我在此声明:如果你是一个工作在2003年或之后的程序员(此文写于2003年10月),并且你还没有对字符,字符集,编码和Unicode有所了解,而且你被我我抓住了,我会罚你在潜艇里剥6个月的洋葱皮!我发誓我会这样做的!
此外还有一事:
这真的没那么难
在本文中,我将会告诉你每个在工作中的程序员所应知道的。所有关于“plain text = ascii = character就是8bit”的知识不仅仅是错误的,而且是错得令人绝望。如果你依然像这样编程,那么你真不比一个不信基因的医生好到哪里去。在读完本文之前,请不要再编写任何一行代码!
在我开始之前,我应该提醒你,如果你是那些少数懂得国 际化的知识的程序员,那么你会发现我讨论的整个话题有那么一点过于简化。我仅仅只是希望在此设立一个门槛,使得每个人都理解关于字符编码究竟都发生了些什 么事情,并有希望使写出的代码可以在任意语言下正常工作,而非仅仅只能工作在在不带方言词汇的英语环境中。我还要再提醒你,想创建可以在国际化语言环境下 工作的软件,字符处理仅仅只是很小的一部分工作,但是我一次只能写一个主题,所以本文就是关于字符集的。
从历史的角度
理解一件事情的最简单的方法,就是回到它发生的时候去。
你可能以为我要在此谈论那些非常老旧的字符集如EBCDIC。嘛,我们不讨论那些。EBCDIC和你的生活没有关联。我们不需要走到那么远古的时期。
回到再近一点的时间,当Unix被发明,K&R正在写那本著名的The C Programming Language的时候,一切都还很简单。EBCDIC正逐渐消亡。那时唯一有意义的就是那些美好的,不包含方言字符的英文字母。于是我们将这套将每一个字符通过32到127之间的数进行表示的编码,记做ASCII。例如,空格是32, 字母“A”是65。这些字符用7个bit就可以存储。那个年代的电脑多数采用8bit为一字节,因此你不光可以用7个bit保存每个可能的ASCII字 符,你还有一个bit的空余,如果你够邪恶,你也可以将之用于自己的狡猾目的:WordStar用了一个很2B的做法——用最高位来标识一个单词的最后一 个字母,这宣告了WordStar仅能用于英文文本。比32小的编码被称为不可打印字符,并且被用来释放诅咒——只是开个玩笑,实际上它们是控制字符,比 如7可以让你的电脑发出蜂鸣声,12可以让当前页纸被送出打印机并传入新纸。
如果你只是一个英语使用者的话,这一切都很美好。
因为一个字节有8个比特,于是很多人就想,“哎,我们可以使用128-255作为自己的用途”。不过麻烦在于,很多人同时有了这个想法,并且他们关于如何使用128~255的想法又各不相同。IBM-PC弄出来一个被称为OEM字符集的玩意,为欧洲的语言提供了一些方言字母,以及一串用来绘制线条的字符… 如水平条,竖直条,右侧带有小的拐角的水平条等。这样,你就可以使用这些画线字符,在屏幕上绘制整洁漂亮的方框与线条了。你至今依然可以在那些那些运行于8088计算机的干洗机上看到这些字符。事实上,当除美国之外的人们开始购买PC时,人们凭空捏造出各种各样的OEM字符集,都将高128位用于自己的用途。举例来说,在一些PC上,字符码130被显示为é,而在以色列销售的计算机,这个字符码则显示为希伯来字母() ,于是,如果美国人将他们的résumés(简历)发送到以色列,这简历在到达后就变成了rsums。在很多场合,比如俄语中,关于如何使用高128位字符有各种各样的办法,因此你甚至无法可靠的交换俄语文档。
最终,这种混乱无序的OEM编码被ANSI标准统一了。在ANSI标准中,所有人都同意对低128的定义,与ASCII码保持一致,高128位编码的处理方式,则取决于你生活在什么地方。这些对高128位编码做不同处理的体系被称为code pages(代码页)。所以,例如以色列的DOS使用 的代码页被称为862,而希腊的用户使用的代码页是737。这些不同代码页在128以下的部分都是相同的,而对于128以上的编码则有不同的处理方案(那 些搞笑的字母皆被含在其中)。MS-DOS的国家版本中包含了很多上述这种代码页,可以处理从英语到冰岛语的一切,他们甚至还包括了少数“多语言”代码 页,可以在一台电脑上同时支持世界语和加利西亚语!WOW!但是话说回来,由于希伯来语和希腊语分属不同的代码页,对大于128的字符有完全不同的解释,因此除非使用位图,否则想在一台电脑上同时支持这两种语言则是一件完全不可能的任务。
另一方面,在亚洲,事情则更加令人抓狂了,因为亚洲的许多语言拥有数以千记的字符,这是无论如何不可能用8 个bit进行编码的。这种情况通常是用“DBCS”的方式进行解决,也即双字节字符集,在双字节字符集中,有的字符使用一个字节进行表示,而有的则需要存 储在2个字节中。这种字符集的问题在于,通常想要在字符串中顺序遍历比较容易,但是要想反向遍历,则几乎不可能。对于这类字符串,程序员们最好不要使用 s++或者s–对其进行遍历,而是最好使用预定义的函数,例如Windows平台上的AnsiNext和AnsiPrev函数,它们知道如何处理这一切乱 七八糟的麻烦事。
但是,绝大多数人依然以为一个字节就恰好对应着一个字 母,或是一个字母就是一个字节。只要你永远不把一个字符串从一台电脑上拷贝到另一台电脑上,或者从来不使用超过一种语言,这种做法就可以在某种意义上正常 的工作。但是,理所当然的,由于因特网的普及,现在将字符串从一台电脑拷贝到另一台电脑变得越来越常见,那么这一切做法的基础就垮台了。幸运的是,Unicode被发明了。
Unicode
针对人们想要创建一个可以囊括这颗星球上一切可能的书写系统的字符集的目标,Unicode 是一次勇敢的尝试。一些人以为Unicode只是一个简单的16bit编码,其中的每个字符都可以拥有16个bit,因而可以支持最多65536种可能的字符,这种对Unicode的认识,事实上,是错误的。这是针对Unicode流传得最广的一种误解,所以,如果你也是这样认为的,不用觉得过于沮丧。
事实上,Unicode针对字符有一套完全不同的思路,因此你必须遵循Unicode看待事物的思维模式,否则你什么都理解不了。
直到现在,我们都认为一个字母可以被映射为若干比特,你可以将之存储于内存中或者磁盘上。
例如 A -> 0100 0001
在Unicode中,一个字母被映射到一个称为code point的东西,这只是一个理论上的抽象概念。至于这个code point如何在内存中表示,或是在磁盘中存储,则又是另一回事了。
在Unicode中,字母A是抽象的形象。它在天堂中漂浮着:
A
这个抽象的Unicode中的A不同于B,且不同于a。但是与A或者A以 及A都是等同的。关键的地方在于,Times New Roman字体中的A与Helvetica字体中的A是相同的字符,而与小写的“a”是不同的字符。这看起来并没有什么有所疑议的,但是在某些语言中,仅 仅指出一个字母是什么就可能引发疑议。德文字母ß究竟是一个真实的字母,还是仅仅只是s的另一种花式写法?如果一个单词末尾的字母的形状改变了,那么这个 字母是否意味着一个不同的字母——请作答?在希伯来文中,上面这个问题的回答为真,而在阿拉伯语中,则为假。无论如何,Unicode协会的那些聪明人们 已经在上一个十年间把这些东西都搞定了,尽管那其中依然包含了一大堆政治上的讨价还价,但是最终的结果是,你不用再为这些麻烦事而烦神了——他们把这些玩 意都搞定了。
Unicode协会为每一个字母表中的每一个抽象字母都赋予了一个Magic number,写起来就像是这样:U+0639。这个Magic number(魔数)就被称为code point。其中的U+意味着“Unicode”,而数字的部分则是16进制的(译注:4位16进制数也就意味着需要16个bit的存储空间)。那么U+0639实际上就是阿拉伯字母Ain。英文字母A则是U+0041。你可以在Windows 2000/XP(译注:在Vista or Win7上也可)使用charmap实用工具来查询这些code point(译注:点击开始菜单,运行,输入charmap回车启动该工具),也可以通过访问Unicode的网站查询。
(译补图:这是charmap实用工具的运行界面,其中英文字母A如图所示恰为U+0041)
并没有人真正对Unicode所能表示字母数目上限进行限制,事实上,Unicode所能表示的字母数目可以超过65536,所以并不是每一个Unicode字母都可以被塞进2字节的空间中,不过这只是个传闻。
OK,假设我们有这样一个字符串
Hello
在Unicode中,这被表示为以下五个code point:
U+0047 U+0065 U+006C U+006C U+006F.
只是一组code point。事实上,也就是数字。到目前为止,我们还没有提到过如何在内存中存储它们,或是在email中如何表示它们。
Encodings
这就是encodings (编码)发挥作用的地方了。
关于Unicode编码的最早的主意是这样的,嘿伙计,咱们把这些数字每个存成2字节吧。(这个主意也正是2字节神话的渊源)于是,我们的Helllo就变成了下面这样:
00 48 00 65 00 6C 00 6C 00 6F
对吗?别着急!为什么不能是下面这样呢:
48 00 65 00 6C 00 6C 00 6F 00
好吧,从技术上说,这样也可以,我确实这么认为,而事实上,由于早期的实现者们在他们要存储Unicode code point的时候,希望依据特定的CPU架构选择是使用大端(high-endian)还是小端(low-endian)模式,这样使得CPU处理速度得 以最佳化。于是,看哪,很快的,就出现了两种不同的存储Unicode的方式。于是人们不得不创造一个离奇的约定,在Unicode的字符串的最前面加上 一个FE FF标识符。这个标识符被称为Unicode Byte Order Mark(Unicode字节序标识符),并且,如果你反转了你的高低字节,那么这个标识符就会变成FF FE,于是读取你的字符串的人就可以知道他们必须要翻转你的每一对高低字节。但是,喔,并不是每一个Unicode字符串都在开头有这个字节序标识符。
一开始,这一切看起来似乎还是挺好的,但是逐渐的,程序员们开始抱怨,“看那一堆没用的0!” ——由于这些美国程序员多数情况下只使用英文文本,也就意味着他们几乎不会用到那些高于U+00FF的code point。尤其是他们多数还是加州的新自由主义嬉皮士,假若他们是德州人,那么他们多半不会在意这些多出来的字节。但是最终,这帮加州的苦孩子们终于无 法容忍字符串存储空间被无端的增长一倍,并且,由于有那么多的文档已经用各种ANSI和DBCS字符集存在了,谁会把这些文档都转换到Unicode下来 呢?难道是我(法语)来?仅仅因为这样的想法,于是在好几年的时间里,大多数人都决定无视Unicode,这使得事情变得更糟。
终于,一个天才的概念被发明出来了——UTF-8。UTF-8在内存中通过8bit的字节来保存U + magic number,定义了保存Unicode字符串的一整套系统。在UTF-8中,0-127之间的code point被保存在一个单字节中。只有那些大于等于128的code point,需要用到2,3以及多至6个字节来保存。
这种做法获得了一个非常不错的副作用——那就是UTF-8中的英文文本与ASCII中的英文文本可以完全的保持一致,于是美国人们都不会发现有什么事情变得不一样了。只有这世界上其他地方的人们不得不跳过这个坑。举例来说,Hello, 这个字符串由code point:U+0048 U+0065 U+006C U+006C U+006F组成,在存储的时候,被保存为48 65 6C 6C 6F,这,恰恰正好与ASCII,ANSI,以及这颗星球上所有的OEM字符集中的表示都完全一样。现在,如果你需要去使用方言字母,或者是希腊字母,或 者是克林贡语字母的话,那么你就不得不为每个code point使用多个字节去存储了,不过美国人永远都不用在意这些了。(UTF-8还有一个非常漂亮的特性,以往的Unicode字符串想使用老式的以单个 byte的0作为字符串的结尾并不至于切断字符串的话需要一些处理代码,而UTF-8则可以忽略之。)
到目前为止,我已经告诉了你Unicode 编码的三种方法。最传统的两字节存储方式被称为UCS-2(因为有2个字节)或者UTF-16(因为有16个Bit),并且你还得自己弄清楚究竟这是一个 大端(High-endian)UCS-2还是一个小端(low-endian)UCS-2。你还可以采用全新的UTF-8标准,如果你只使用英文文本, 它会让你在碰到一个无脑程序时,即便它完全无视了除ACSII之外的一切,你依然会过得很幸福!
事实上还有其他一系列方法来编码Unicode。 有一种编码叫做UTF-7,它与UTF-8有很多相似之处,但是它假定所有字节的最高比特都是0。因为这个原因,如果你要把Unicode字符串传递给一 个严格认为7bit就足够用的警方邮件系统,那么感谢UTF-7吧,它能使你免于痛苦。还有种UCS-4编码,它使用4个字节存储一个code point,它有个不错的特性,每个code point都是等长的,但是麻烦在于,它是在浪费了太多的内存,以至于即便是得克萨斯人也不敢使用它。
事实上你正在使用Unicode code point所表示的柏拉图式的理想的字母来考虑这些问题,这些Unicode code point同样也可以使用任何一种旧的学院派的编码方案来表示。举例而言,你同样可以用ASCII或古希腊OEM编码或希伯来ANSI编码,乃至迄今为止 已被发明的数百种编码,来表示一个Unicode编码的字符串Hello(U+0048 U+0065 U+006C U+006C U+006F)。但是这些做法有一个陷阱,那就是某些字母可能不能正常显示!如果你想要将某个Unicode code point在某种编码中表示,而该编码中又没有能对应上该code point的,你常常会得到一些小的问号:?或者,如果你的人品不错,你会得到一个�
存在数以百计的传统编码,它们都只能正确表示Unicode code point的某些子集,而对那些处理不了的code point,则用问号来处理。有一些流行的英文编码,诸如Windows-1252(这是Windows 9x中的西欧语言标准),以及ISO-8859-1,aka Latin-1(同样在任何一个西欧语言中都会有用的),如果你想要用上面这些编码来处理俄文或者是希伯来字母,那么你会得到大量的问号。与之对应的,UTF7,8,16以及32等编码则有非常棒的特性,它们可以正确表示任何Unicode code point。
关于编码的最重要的事实
如果你把我之前所解释的所有一切都忘光了,请你至少记住一个最最最重要的事实。如果你有一个字符串,而不知道它的编码,那么这个字符串是毫无意义的。你再也不能把脑袋埋到沙子里,然后假装“普通”文本就是ASCII。
这世界上就没有普通文本这回事。
如果你有一个字符串,不管是在内存里,还是在文件里,还是在一封email中,你必须知道它的确切的编码,否则你不可能做到正确的解释它,或是向用户显示。
几乎所有的像是“我的网站看起来像是在胡言乱语”,或者是“如果我在邮件里用了方言字母,那么她就无法阅读”的傻逼问题,几乎都是由于某些天真犯二的程序员没能理解下面这个事实:如果你不告诉我一个特定的字符串用的是哪一种编码,UTF-8或是ASCII或是ISO 8859-1(Latin 1)或是Windows 1252(西欧),那么我就不可能正确的显示这个字符串,甚至不可能知道哪里是它的结尾。有上百种编码格式的存在,并且一旦出现大于127的code point,那么一切就全完了。
一个字符串是如何编码的,我们要如何维护这样一个信息呢?好吧,关于这件事情,有一些标准做法。对于email消息,你最好在正文的头部,加入这样一行字符串:
Content-Type: text/plain; charset=”UTF-8″
对于网页页面,最早的想法是由web服务器返回一个类似的Content-Type,把这个信息放在与网页内容一同传输的HTTP报头中,不是放在HTML页面里,而是放在响应报头里,在HTML内容之前被发送。
但这种做法会导致问题。想象一下你有一个巨大的web 服务器,上面跑着很多网站,并且有着由不同的人贡献的数以百计的网页。这些人创建网页的时候,他们所使用的Microsoft FrontPage可能以任何它觉得合适的方式来选择编码进行存储。web服务器本身对此一无所知,它不可能知道每一个文件是以什么编码格式写的,所以它 也就无法为之发送Content-Type头。
如果你使用某种特殊的tag,把 HTML文件所使用的Content-Type直接写到HTML文件里,就会让后续的事情变得更加方便。当然这种做法会让某些纯化论者感到抓狂…如果你不 知道这个HTML文件的编码,你要如何去读取它?!幸运的是,几乎所有的编码对32到127之间的字符都是同样对待的,于是,在需要使用任何诡异的字母之 前,你总是可以在HTML页面中读取到至少像下面这样多的内容:
<html>
<head>
<meta http-equiv=”Content-Type” content=”text/html; charset=utf-8″>
但是需要注意的是,这个meta tag必须是<head>节中最先出现的东西。因为只要网页浏览器见到这个tag,它就会停止解析这个页面,并且使用你所指定的编码开始重新解释整个页面。
如果网页浏览器即无法从http报头也 无法从HTML的meta标记中找到任何Content-Type信息,那么它们会如何对待这个网页呢?事实上,Internet Explorer做了一些有趣的事情,它基于一种启发式的方法去猜(依照典型编码的典型文本中,各种不同的语言使其字节呈现不同的分布频率的规律)。因为 各种旧的8bit代码页会尝试把他们国家的字母放在128~255中不同的区间段里,同时又由于每一种人类语言对字母的使用都呈现不同的统计特征,所以上 述方案,有一定的概率是可以工作的。这做法是很怪异的,这使得那些天真无邪的网页作者,在从来不知道每个HTML页面都需要一个Content-Type 头的情况下继续写网页,并且当他们在浏览器中查看时,发现一切都是正常的。直到有一天,他们写了点东西,与他们母语中的字母频率分布不相符合,于是 Internet Explorer便认为这是一段韩语,然后,就这么继续显示出来……我相信,这证明了Postel法则中所说的,“宽容的对待输入,而保守的输出”,坦白说并不是一个好的工程原则。无论如何,当这个网站的可怜的读者,在面对这个被显示成韩语(并且事实上是根本无法理解的韩语)而事实上是保加利亚语的网页时,要怎么做呢?他使用View|Encoding菜单,并且依次尝试每一个编码的选项(至少有十数个东欧语言选项),直到一切变得正常。但是,事实上,多数人都不知道要这么做。
在我公司所发布的网站管理软件CityDesk的最新版本中,我们决定内部的一切都用UCS-2(2字节)Unicode表示,这也是Visual Basic,COM以及Windows NT/2000/XP所使用的原生的字符串类型。
在C++代码中,这意味着当定义字符串时,我们使用wchar_t(宽字符)来替代char,并且使用wcs系函数来替代str系函数(例如,使用wcscat和wcslen而不是strcat和strlen)。要在C代码中创建一个UCS-2的字符串,你只需要在字符串定义前增加一个L,如:L”Hello”。
当CityDesk发布网页时,它将之转换为已经为网页浏览器所支持多年的UTF-8编码格式。这也是Joel on Software的全部29种语言版本所使用的编码,并且我从来没有听到过任何一个人抱怨说在阅读它的时候遇到麻烦。
本文的篇幅有点长,并且我也不可能覆盖关于Unicode和字符编码的所有话题,我希望的是,如果你已经阅读到这里,你已经知道了足够多的知识,我留给你的任务,就是回去编写程序,并且记得在对付疾病的时候使用抗生素,而不是水蛭和魔咒。
还想知道更多?你现在阅读的是Joel on Software,这里填满了各种经年累月积累下来的关于软件开发,管理软件团队,设计用户界面,成功运营一家软件公司,以及橡皮鸭的各种胡言乱语的文章。
关于作者:我 是Joel Spolsky,Fog Creek Software的共同创始人,Fog Creek Software是一家纽约的公司,它证明了你可以在对待程序员们很好的同时创造出很高的利润。这里的程序员拥有私人的办公室,免费的午餐,以及每周40 个小时的工作时间。公司的客户只为他们满意的软件付费。我们创造了一个更先进的bug跟踪软件FogBugz,以及软件开发工具。Kiln,一个分布式的源代码管理系统,如果你迷恋svn,它会让你非常惊喜,以及Fog Creek Copilot,可以让访问远程桌面变得更加容易。我同时也是Stack Overflow的共同创始人。
译注:本文对Unicode和编码的解释非常棒,将编码和字符集的来龙去脉解释得深入浅出,是每个合格的程序员所必知必会的基础知识,本人在阅读《Game Engine Architecture》一书时,了解到此文,遂生出翻译的兴趣,翻译的过程中确又发现再次理清了若干此前未曾真正理解的概念,只因英文水平和精力有限,难免有所错漏,如有指正,不吝感激。