每个开发必须了解的Unicode和字符集的那些事!
raledong发布于 3 月 27 日
你曾经对神秘的Content-Type标签感到好奇吗?就是那个在HTML中经常用到但是很少有人了解为什么要去使用它的标签。
你曾经收到过一封来自保加利亚的朋友发给你的邮件,邮件的标题是“???? ?????? ??? ????” ?
我很失望的发现有非常多的软件开发者并不了解字符集,编码,unicode等相关的知识。几年前, FogBUGZ网站的一个测试人员想要知道它是否能够成功接收来自日本的邮件。日本?日本也要用这个邮件系统?我一头雾水。在仔细研究用来解析MIME邮件消息的商业ActiveX控制器后,发现它解析字符集的方式是完全错误的,所以我们不得不大胆的写一些代码来纠正错误的转化使其正确解析。看了其他的商业化代码库之后,发现它们的字符解析实现也非常的简陋。我联系了那个库的开发者,他们的态度是“我们啥都做不了”。和很多程序员一样,他希望这件事情可以就这么过去了。
但是显然这个问题不能就这么算了。当我发现PHP这个如此流行的Web开发工具都几乎完全无视了字符编码的问题, 随意的用着8位存储的字符,使得几乎无法用其开发国际化网页应用。我觉得真的够了,再也忍不了了!
所以在此我要郑重声明:如果你现在是一名程序员却不了解字符,字符集,编码和Unicode的基础知识,一旦被我发现,我就要罚你到深海潜水艇上寂寞的剥6个月的洋葱!
我还要说一点,这个问题并没有想象中的那么难!
这篇文章我会聊一些每一个程序员所必须知道的内容。什么“plain text = ascii = 8位自符”这些东西简直是大错特错。如果你还用那种思路编程,就仿佛是一个不相信细菌存在的外科医生。请在阅读完本文之后再去继续你的编码生涯。
在开始之前,我要提醒那些极少数了解国际化编程的同学,你们会发现这篇文章的内容有些过度简化。因为我只分享了最基础的内容,从而让每一个人能够理解并且试着写出一个非英语环境下都能够正确运行的程序。我还要声明,正确的字符编码只是国际化程序能够良好运行的一个很小的前提,但这次不扩大范围,先只聊这件事。
历史的视角
了解这个问题最好的方式就是沿着时间线追溯。
你可能以为我要说一说非常古老的字符集EBCDIC,但是我不~EBCDIC已经和我们现在的编码无关了,我们不需要追溯那么远的历史。
在上古时期,当Unix刚刚被发明出来,K&R还在写C语言的时候,一切都是那么的简单。EBCDIC刚刚被淘汰出局,我们只需要关注一种字符类型,那就是英文字母。我们使用了一种叫做ASCII的编码方式,通过32和127之间的数字来表示任意一个字符。比如Space的编码是32,A的编码是65。这种编码可以用7位轻松存储。那个年代大多数的电脑都使用8位字节,因此我们不仅可以存储每个ASCII码字符,还有一个空闲位来支持一些控制指令,比如7可以表示让电脑告警,12可以命令打印机的当前页移出并引入新的纸张。
一切看上去是那么美好,前提是你是一个英文开发者。
因为一个字节有8位而ASCII编码只用了其中的7位,很多人都开始想,“诶哟,我们可以自定义128~255这个区间所代表的字符”。问题是,当时很多人同时产生了这个想法,并且发明了各式各样的自定义编码映射。IBM电脑提出了一个称为OEM的字符集,其中包含了一些欧洲语言中带有音调的字符和一些绘图式字符… 比如水平线,垂直线,带有小箭头的水平线等等。你可以用这些线状字符在屏幕上绘制出精美的盒子形状图形,直到现在还能在一些装有8088芯片的洗衣机上看到这些图形。事实上,随着美国之外的人们开始买电脑,各种各样的字符集应运而生,各自都有着不同的含义。比如,在一些电脑上130编码代表é,但是在一些以色列售卖的电脑上却是希伯来语Gimel(
)。所以当美国人将résumés发送到以色列,它将被翻译成r
sum
。甚至是一个国家内,比如俄罗斯,对于128位以上的字符都有很多不同的映射,所以同一份俄语文件都可能被解释成不同的内容。
最终,这些随意的OEM编码们在ANSI标准中得以改变。在ANSI标准中,每个人对于128以下的编码内容达成一致,这部分基本和ASCII编码,但是对于128以上的编码映射在不同的地区有不同的处理方式。这些不同的区域编码系统被称为_编码页_。比如在以色列的DOS系统中用的编号862的编码页,而希腊用户使用编号737的编码页。这些编码页在128以下的内容相同,但是在128位以上的字符就五花八门了。MS-DOS的国际版本有几十个这样的编码页,用于处理各种各样的语言,甚至有一些编码也能够同时支持多种语言!但是,换句话说,要想用一个编码页在一台电脑上同时支持希伯来语和希腊语是不可能的,除非写一个自定义的程序来展示位图图形,因为希伯来语和希腊语需要使用不同的编码页来翻译高位的编码。
于此同时,在亚洲,编码变得更加疯狂,因为亚洲的语言通常有上千个字母,根本无法只用8位来表示这些字母。这个问题通常用一个叫做DBCS(double byte character set)的很糟糕的系统来解决,这个系统中部分字符用一字节来表示,一些用两字节来表示。这样的设计使得在string中从前往后遍历很轻松,但是几乎不可能从后往前遍历。程序员通常被建议不要使用s 或者s--来前移或后移,而是调用函数如Windows的AnsiNext和AnsiPrev,让操作系统决定如何处理这些字符。
即便如此,很多人依然认为一个字节就是一个字符,一个字符是8位。只要不将这个字符串移动到另一台电脑上,或者这个字符串不涉及别的语言,这一切都看上去很正常。但是,随着国际化趋势,将字符串移动到另一台电脑变成了一件很常见的事情,于是一切开始崩塌。幸好,Unicode随之问世了。
Unicode
Unicode做了一个大胆的尝试,它创建了一个字符集编码将这个星球上所有的合理的或是编造的(如Klingon)语言都囊括进来。有些人误以为Unicode就是一种长度为16位的编码,每16位代表一个自负,因此一共有65,536中可能的字符。这个理解不完全正确。这也是对于Unicode最常见的误解。所以如果你也是这么认为的,不用觉得沮丧。
事实上,Unicode用一种全新的方式来翻译字符。试着用它的方式来思考才能够真正明白Unicode的编码方式。
现在,我们假设一个字母被映射成一些二进制位从而能够存储到磁盘或者内存中:
A -> 0100 0001
在Unicode中,一个字母映射到一个称为代码点(code point)的东西,这仍然只是一个理论上的概念。至于这个代码点是如何在内存或者磁盘上表示的就是另一个问题了。
在Unicode中,A这个字母是一个理想化的符号。这个理想化的A不等于B,也不等于a,但是和 不同形式的_A_ 和A却是相同的。在一种字体下的A和另一种字体下的A被认为是一个符号,但是和小写的a相比就是不同的符号。这看上去没什么争议,但是在一些语言中明确一个字符究竟是什么就会产生争议。比如德语字母ß究竟是一个理想化的符号还是只是用来表达ss的简写?如果一个字母的在单词末尾时形状改变了,那它是否是另一个字母?希伯来语对这个问题的回答是肯定的,但是阿拉伯语却不是。总而言之,那些发明Unicode的聪明人儿在过去十年将这个问题想明白了,虽然伴随这很多高度政治化的争论,但是他们终究还是梳理清楚了。
每一个理想符号都被分配了一个类似于U 0639的魔法值。这个魔法值被成为代码点(code point)。U 代表是Unicode编码,后面紧跟着十六进制的数字。U 0639代表阿拉伯字母Ain,而英文字母A则是U 0041。你可以在Windows 2000/XP的charmap工具或者Unicode网站上查看全部的编码信息。
Unicode能够定义的字母数量其实没有上限,它们早就超过了65,536个字母,所以并不是每个Unicode字母都能够被压缩进两个字节,这个问题到本文目前为止还是一个谜。
好了,假设我们现在又一个字符串Hello,在Unicode中对应这么5个代码点U 0048 U 0065 U 006C U 006C U 006F。至于这些代码点将如何在内存中存储或者在邮件中展示,我们还没有做介绍。
编码
接着就要聊一聊编码了。
早期Unicode的编码采用了两个字节来存储,所以Hello这个单词被编码成00 48 00 65 00 6C 00 6C 00 6F。看上去还不错~等下,那是不是也可以被编码成48 00 65 00 6C 00 6C 00 6F 00。事实上这么编码也不是不可以,而早期的开发者希望能够根据具体的CPU架构来选择是采用高位模式还是低位模式来进行存储。所以人们不得不遵循一种奇怪的约定,在每个Unicode字符串前存储一个FE EF前缀,这个前缀被称为Unicode字节顺序标记位(Unicode Byte Order Mark)。而如果你将字符串的高低位对换位置后,你就需要加上FF FE前缀,从而让阅读者知道这里需要做一次交换。但是,并不是每一个Unicode字符串的开头都有字节顺序标记位的。
这样一度看起来很不错,但是有些程序员开始抱怨了。“嘿!看这一大串零!”,因为这些人是美国人,而英文很少会用到 U 00FF以上的编码。这意味着这些零导致的双倍的存储空间。而且现在已经有了那么基于ANSI和DBCS字符集编码的文档,谁来将他们转换成Unicode编码。因此很长一段时间大多数人都无视了Unicode编码,而于此同时,编码不统一带来的问题开始变得越发严重。
因此UTF-8随之诞生。UTF-8是另一个使用8比特位将Unicode代码点的字符串(那些神奇的U 数字)存储在内存中的系统。在UTF-8中,每个0-127之间的代码点用一个字节来存储,只有128及以上的用2,3个甚至6个字节来存储。
这种设计最大的好处就是英文的编码和ASCII编码一摸一样,所以美国人几乎不会发现有什么区别,而其它国家则气的跳脚。比如Hello,本来应该是 U 0048 U 0065 U 006C U 006C U 006F,会被存储成48 65 6C 6C 6F。就和ASCII,ANSI和任何OEM字符集编码产生的内容一样。现在,假如你大胆的使用一些其他国家的语言如希腊字母或克林贡字母,你就需要用额外的字节来存储一个代码位。(UTF-8还具有一个不错的属性,即那些使用单个0字节作为空终止符的老旧字符串处理UTF-8代码不会截断字符串)
目前为止我已经告诉你Unicode编码的三种方式,传统的那种全部用两个字节存储的方法叫做UCS-2(因为它由两个字节构成)或者UTF-16(因为它有16位),但是你依然需要区分是高位的UCS-2或者是低位的UCS-2。还有就是比较流行的UTF-8标准,可以同时兼容英语字母的历史编码和其它语种的编码。
还有一些别的Unicode编码方式,比如有一个叫做UTF-7,它和UTF-8很类似,但是它确保高位永远都是0.所以如果你想要将Unicode在某些邮件系统中传递,而7位的长度已经足够,那么这种编码能够提供很好的压缩。还有UCS-4,它用4个字节来存储每个代码点,因此每个代码点编码后都是等长的。但是很少有人能够接受这样的存储空间浪费。
现在当你再看看这些用Unicode代码点表示的每一个理想字符,这些Unicode代码点可以用任何一种老式的编码工具进行编码。比如你能够将Hello这个Unicode字符串用ASCII或者老式的希腊OEM,或者希伯来ANSI进行,或者上百种现有的编码方式进行编码。但是可能有一个问题,一些字母可能展示不出来。如果Unicode的代码点在当前的编码集中没有对应的字符,它可能会变成一个小小的问号?
大多数的传统编码只能正确的存储部分代码点,而其他的代码点会被翻译成问号。一些比较流行的英文文本编码如Windows-1252 ,ISO-8859-1,当你是这用这些编码来翻译俄文或者希伯来文时,你会生成一大堆问号。UTF 7, 8, 16, 和 32都能够正确的存储任何的代码点。
关于编码必须知道的最重要的一点
如果你已经忘了我刚刚说的一切,请至少记住最重要的一点。当你拿到一个字符串却不知道它的编码的话,这个字符串本质上毫无意义。你不能在把脑袋埋在沙堆里假装它默认是ASCII编码。这世界上不存在默认编码这回事!
如果你在内存、文件或者邮件中有一个字符串,你必须知道它的编码格式,否则你无法正确的翻译或展示它。
几乎每一个愚蠢的问题,如“我的网站看上去在胡言乱语”或者“我使用方言的时候她看不懂我的邮件”,都来自于一个不懂这个简单道理的天真的程序员。如果不告诉你这个字符串是用UTF-8 还是 ASCII还是ISO 8859-1 (Latin 1)还是 Windows 1252 编码的,你根本没法正确的展示它,或者是找到这个句子的结束符。这世界上有上百种编码,猜测127之上的编码方式就是一种徒劳。
Content-Type: text/plain; charset="UTF-8"
对于一个网页,最初的想法是web服务端返回一个类似Content-Type的HTTP请求头和相应的网页。也就是说不是HTML网页本身携带Content-Type定义,而是让请求头来标记这个网页的编码。但是这种方式带来了一些问题。假如你拥有一个大型的web网站和大量的网页,这些网页由来自各个国家的人用不同的语言参与开发,并且使用了开发工具推荐的各种各样不同的编码。web服务器自己都不知道每个文件具体的编码形式,因此它无法确定Content-Type头的内容。
相比而言,直接将HTML文件的Content-Type用特殊的标签保存在HTML正文中就显得更加方便一些。当然这可能让一些追求极致的人抓狂...你怎么能在解析了HTML后才知道具体的编码格式呢?幸好,几乎每一种编码在32和127之前的实现是基本类似的,所以你可以在解析如下的HTML的时候得到正确的内容:
<html> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
但是这个meta标签一定要放在<head>标签中的第一位,因为web浏览器一旦读取到这个标签就会暂停解析页面,并使用指定的编码重新翻译。
如果web浏览器没有在http报文头或者meta标签中找到Content-Type信息怎么处理?IE浏览器会做一件很有趣的事情:它会基于当前不同字符出现的频率来猜测使用的语言和编码。因为不同的语言对于字符有不同的使用规律,这个功能还真的有一定的可用性。这也是为什么一些天真的网页开发人员发现即使不加入Content-Type标签,网页看上去也很正常,直到有一天他们编写了一个不遵循他们母语使用规律的网页,而IE判断出这是一个韩国网页并按照相应的编码进行解析。这也证明了伯斯塔尔法则所说的“接受多变,输出保守”并不是一条很好的软件工程法则。总之,那些可怜的网站用户在看到本应该是保加利亚语编写的网页被翻译成韩语(甚至不是连贯的韩语)时会怎么办?他可能会使用View | Encoding工具并尝试一系列不同的编码,直到生成一个看上去正常的结果页。前提是他知道浏览器有这么一个工具,而其实大多数人都不知道这个功能。
unicode
本文系翻译