你应该知道的Unicode
我是个Unicode新手。然而正如许多被Unicode导论一文刺激的新手一样,我迫切的想弄清楚Unicode到底是啥。
Unicode的确涉及到了一些CS的底层概念,如字节序,然而却并不难理解。学习Unicode的同时,我们还能了解设计中的权衡及向后兼容的知识。
下面是我的学习心得。该文可以直接阅读或者是作为上面Joel写的那篇文章的补充。读完本文,你可能会有去读Unicode规格或者Wikipedia的冲动。我保证,这是非常值得做的。
咱们先就以下几点达成共识:
概念和数据不是一回事。字母“A”不仅仅是报纸上的一个标记,一个发音“aaay”或者在计算机中存储为65的东西。
一个概念有许多种编码方式。所谓编码,那就是将一个概念(如字母“A”)转化为一个原始数据(比特和字节)的方式。“A”可以被编码为多种数据格式。各编码方式之间的不同点就在于效率和兼容性。
请明确编码。读取数据时,你必须知道其编码才能正确解读。这点很简单,却很重要。当你看到数字65的时候,会想这是什么意思呢?ASCII编码的“A”?还是一个人的年龄?亦或是一个人的IQ值?很明显,除非有上下文,否则无法得知。想象某个家伙上来直接跟你说“65”,鬼知道他说的什么。如果他说的是“ASCII编码65”。也很傻逼对吧,但是意义却明确多了吧?
好好琢磨下这里的哲学,概念和用于传达概念的数据不同。
有些感觉了吗?咱们继续深入。
你大概知道ASCII/ANSI字符集吧。它定义了数值0-127到西方字符以及一些控制码(换行符、制表符等等)的对应关系。注意0-127只占用了一个字节的低7位。ASCII没有明确的规定128-255对应哪些字符。
ASCII可以很好的用于英文(使用西方字符),然而阿拉伯语,汉语和希伯来语该如何表示呢?
为了解决这个问题,计算机生产商使用剩余的128-255定义了“代码页”,用来映射到不同的字符。然而,剩余的128个字符根本不够用,因此代码页就有许多种(俄文代码页,希伯来文代码页,等等)。
如果交换数据的双方使用相同的代码页,这没问题。但是如果发送方用俄文代码页,接收方使用希伯来文代码页,这就一团乱糟。
数字200对应的俄文字符和希伯来文字符肯定不一样。接收信息的一方是否使用跟你相同的代码页那就是个问题了。如果访问国际性网站,如果网页中没有指定代码页,浏览器就需要进行猜测。显然代码页这不是一种好的方案,急需改进。
现在的难题是,ASCII的某个数字对应哪个字符无法达成一致。这时,Unicode委员会出来说,本质上字符是一个抽象概念。然后给每个抽象字符分配一个代码点(code point)。例如,“A”对应代码点U+0041(这是十六进制形式,十进制为65)。
Unicode委员会不辞辛苦地给每种语言中的字符分配了代码点(我相信这不需要过多的争论)。Unicode标准预备一百多万个代码点,足够所有已知和未知的语言使用。有兴趣的话,代码点可以通过字符映射表程序(开始>运行>charmap)或者Unicode.org查看。
这么做遇到的第一个设计决定就是:兼容性。
为了与ASCII兼容,代码点U+0000到U+007F(0-127)跟ASCII相同。由于完整的拉丁字符集被定义在了其他地方,再者一个字符有两个代码点,纯粹主义者肯定是不太喜欢这种设计。还有就是西方字符被放在开始部分,中文,阿拉伯文以及一些“非标准”语言被分配了需要两个字节来存储的代码点。
然而,这种设计是必要的。ASCII是以前的标准,如果Unicode需要被西方世界采用的话就必须兼容ASCII。现在绝大部分的通用语言都可以容纳到开始的65535的代码点中,可以使用2个字节存储。
这下好了,人人同意这个方案,世界变得多么美好。
然而接下来的问题就是,如何存储代码点?
最开始就提过,将概念转化为原始数据的方式就是编码。在这儿,代码点就是要转化的概念。
首先看ASCII是如何编码用来存储Unicode代码点的。规则很简单:
U+0000到U+007F的代码点用一个字节存储,U+0080以上的代码点直接扔掉,很简单吧。
正如你所看到的,ASCII用来存储Unicode并不理想。如果你有一个Unicode文档,然后将之存为ASCII,所有的特殊字符都丢了。当你在文本编辑器里将Unicode数据存储为ASCII时一般会有警告。
这里拿ASCII举例是为了说明,编码就是一种将概念转化为数据的系统。只不过,ASCII这套系统不怎么可靠。
可以做如下实验,要用到记事本(可以读写Unicode)和一个程序员专用记事本(具有十六进制编辑功能,因为需要知道记事本存储的原始数据)。
- 打开记事本输入“Hello”
- 将文件分别另存为ANSI, Unicode, Unicode Big Endian, UTF-8格式。
- 用程序员专用记事本打开文件,并且选择菜单项View > View Hex。
使用十六进制编辑器打开另存为ANSI(ASCII)的“Hello”,是这样的:
字节: 48 65 6C 6C 6F
字符: H e l l o
ASCII编码之所以重要,是因为许多工具和通信协议仅仅接受ASCII字符。它还是被广泛接受的最小文字编码。由于它的广泛接受度,一些Unicode编码会将代码点转化为一系列的ASCII字符以便传输。
现在我们知道上面的数据是文字,因为我们自己写的嘛。如果那个文件是偶然发现的,我们可能通过上下文推测那是ASCII文字,但也有可能是一个账号数字或者其他数据,只不过恰好也是ASCII的“Hello”。
通常,根据特定的头和特殊位置的“幻数”(特殊字符序列),可以对数据做出不错的猜测。然而这不是确定的,有时会有失误。
不相信吗?咱们做个试验
- 打开记事本
- 写入“this program can break”
- 保存文件为“blah.txt” (或者其他
- 在记事本中打开该文件
欧,发生了啥? 这个留作一个练习
在我初次听说“Unicode”时,首先想到就是这种编码——一个字符使用两个字节存储(多么浪费啊!)。基本上,这种方案可以处理代码点0x0000到0xFFFF,或者说0-65535。65535个字符应该对每个人都够用了吧(其实这种编码可以存储65535以上的代码点,请参考Unicode规格)。
使用多个字节存储会带来字节序的问题。一些计算机首先存储小端字节,一些则相反。
要解决这个问题,可以这么做:
- 方案 1:选定一种字节序(大端或者小端)强制必须如此使用。这不太现实——每次打开文件,使用错误端序的计算机为此都要付出效率折损的代价。
- 方案 2:在文件开头放入一个字节顺序标志(byte order mark,BOM)。打开文件时,如果BOM是反向的,则转换。
最终的解决方案就是使用BOM头:UCS-2编码可以使用U+FEFF作为文件头。如果文件开头两个字节分别是FEFF,那么文件可以直接使用。如果是FFFE,那么文件的字节序需要转换。那就是交换文件中的所有字节。
然而事情并非这样简单。事实上BOM也是一个有效的Unicode字符——如果一个人发送了一个不包含文件头的文件而BOM正是文件内容的一部分,那该怎么办?
这是Unicode中的公开问题。给出的建议是,除非用作BOM,避免使用U+FEFF做文件内容,应该使用替代品。
这引出了设计上的第二个值得关注的地方:多字节数据会有字节序的问题!
ASCII不需要担心字节序——每个字符使用一个字节,不会有错乱。但是在实际中,如果看到文件开头的0xFEFF或者0xFFFE,这很大可能是Unicode文件开头的BOM,用来表示字节序。
(补充:UCS-2使用16位存储数据。UTF-16允许将至多20位分散到两个16位字符中来组成一个所谓替代对(surrogate pair)。替代对中的每个字符都是一个有效的Unicode字符,然而组合在一起就可以用来表示另一个有效字符。)
在记事本中输入“Hello”,然后存为Unicode(实际上是小端UCS-2,此为Windows上的本地格式)
Hello-little-endian:
FF FE 4800 6500 6C00 6c00 6F00
header H e l l o
然后存储为Unicode Big Endian
Hello-big-endian:
FE FF 0048 0065 006C 006C 006F
header H e l l o
注意:
- BOM头(U+FEFF),正如上面说的,FF FE代表小端, FEFF代表大端。
- 不管什么字母均为两个字节:“H”在ASCII中是0x48,在UCS-2中是0x0048。
- 编码方式很简单。将代码点用2个字节表示出来即可。不需要额外的处理。
- 编码方式过于简单了。对于简单的ASCII文字,高位字节无用,纯粹浪费了。而ASCII又是使用很普遍的。
- 基于ASCII编写的老式程序遇到null时会认为该Unicode字符串已经结束。这明显有问题。
设计中要注意的第三点:考虑向后兼容性。旧程序读到新数据会发生什么?如果能忽略还好,如果受到新数据影响就不能接受了。
UCS-2/UTF-16不错也很简单,但是浪费了空间。不仅仅占用了ASCII两倍的空间,还由于null字符的存在不方便读取。
说到UTF-8。它的目标是尽量少用字节(在处理ASCII时),而且不会插入null字符。它是XML的默认编码。
更详细的内容请阅读UTF-8的规格,简而言之如下:
- 代码点0 – 007F被存储为单字节ASCII。
- 代码点0080及以上被编码为一系列字节。
- 起始的“计数”字节表示包含计数字节在内的字节数目。这些字节以11..0开头:
110xxxxx (起始的“11”表示序列中有两个字节,包括计数字节)
1110xxxx (1110 -> 3个字节)
11110xxx (11110 -> 4 个字节) - 以10…开始的为“数据”字节,包含代码点的真实信息。一个两字节的示例如下:
110xxxxx 10xxxxxx
这表明序列中有两个字节。X代表了代码点的二进制数据。
关于UTF-8需要注意:
- 无null字节。所有的ASCII字符(0-127)均如此。非ASCII字符最高位均以“1”开头。
- ASCII文字的存储很高效
- 以“1”开始的Unicode字符可以被基于ASCII的程序忽略(然而,有时可能被舍弃!参看UTF-7了解更多内容)。
- 这儿有一个时间和空间的权衡。对每个Unicode字符都需要处理,然而这是可以接受的。
设计原则四:
- UTF-8的处理了80%的情况(ASCII),同样也使得其他情况成为了可能(Unicode)。UCS-2不偏不倚地对待所有情况,使得80%的情况变得低效。然后UTF-8编码对于Unicode字符需要些额外的处理,UCS-2这点占优。
- 为何XML使用UTF-8存储?在读取XML文档时空间和处理能力哪个更重要?
- 为何Windows XP使用UCS-2存储字符串?对于操作系统来说空间和处理能力哪个更重要?
无论如何,UTF-8也需要一个头来标识文字是如何编码的。不然可能会被解析为ASCII加上一些额外的代码页。它依然使用U+FEFF代码点来作为BOM,但是BOM自身也用UTF-8来编码(很聪明吧)。
Hello-UTF-8:
EF BB BF 48 65 6C 6C 6F
header H e l l o
ASCII文字在UTF-8编码中没有改变。你可以随意地在charmap程序中复制一些Unicode字符然后观察它们是如何被存储的。这可以在线试验。
即使UTF-8用于ASCII非常好,但是对于Unicode数据的存储仍然要将高位置1。一些邮件协议不允许非ASCII值,因此UTF-8的数据不能很好的发送。一些系统不理会高位数据。然而有些系统要求数值在0-127范围内(如SMTP)。如何通过这些系统发送Unicode数据呢?
说起UTF-7。它的目标是将Unicode数据编码为与ASCII兼容的7位(0-127)。UTF-7编码规则如下:
- 代码点在ASCII范围内的字符,除了有特定含义的如(+,-),都使用ASCII存储。
- 代码点不在ASCII范围内的字符,转化为二进制值,然后存储为base64编码(存储ASCII的二进制信息)
那么怎么知道哪个ASCII字母为真的ASCII,哪个是base64编码的呢?很简单。被特殊符号“+”和“-”包围起来的是base64编码字符。
“-”充当一个转义后缀符。出现在它前面的字符就是字面量。因此“+-”被解析为“+”而没有特殊编码。这也就是UTF-7中“+”的存储方法。
Wikipedia有一些UTF-7的示例,而记事本不能存为UTF-7。
“Hello”跟ASCII并无二致 — 这儿都是ASCII字符,没有特殊字符。
Byte: 48 65 6C 6C 6F
Letter: H e l l o
“£1”(1英镑)是这样的
+AKM-1
字符“+AKM-”意味着AKM应该使用base64进行解码然后转为字符点0x00A3,对应着英镑符号。“1”保持不变,这是个ASCII字符。
UTF-7很巧妙吧?它基本就是消除最高位置位的字符后Unicode到ASCII的转化。大部分的ASCII字符看起来都是一样的,除了需要转义的特殊字符(-和+)。
我依然是个新手,不过我还是学到了很多关于Unicode的知识:
- Unicode并不是意味着2个字节。Unicode只是定义了代码点,可以被编码为多种方式(UCS-2,UTF-8,UTF-7,等等)。各编码在简洁性和效率上不同。
- Unicode拥有不止65535(16bits)个字符。编码可以指定更多的字符,然而开始的65535涵盖了大多数通用语言。
- 在正确读取一个文件前你需要知道其编码。虽然根据字节顺序标记(BOM)可以猜测文件的编码,然而也可能弄错。即使文字看起来像ASCII,实际上也可能是UTF-7编码。
Unicode是个有趣的学习过程。让我学到了如何在设计中进行权衡,并且明白了分离核心概念和具体编码方式的重要性。
点击查看原文
- 上一篇:没有了
- 下一篇:没有了