5.2 Unicode
前面章节已经做了简要说明,现在的Linux和Windows中,宽字符大多使用的是Unicode。作为crowbar处理器,如果想要制作一个转换宽字符和多字节字符的程序库,可以不用考虑实际的 wchar_t中存储的字符到底是什么字符编码。但是,作为一名程序员是不能回避Unicode问题的,所以本章将对此进行介绍。
5.2.1 Unicode的历史
Unicode是由Xerox提出,并由Unicode Consortium制定的字符编码。
Unicode最初的内容是“用16位表示全世界所有的字符”,所以,中国、日本、韩国使用的汉字只要字形是一样的,都会分配到同样的字符编码(Unicode中正确的叫法应该是码位,即code point)。截止到1990年12月的最终草案,汉字分配了0x4000~0xE7FF,共18739个字符。
在中国,GB2312(EUC-CN)能表示的汉字总共有6763字,由此得知,在Unicode出现之前使用普通PC可以处理的汉字,全部可以用Unicode表示。但是,考虑到经常有一些人名或者古汉语中的字符无法用国标汉字表示,因此即使是18739个字也不一定够用,更何况这个范围内还包含了日本和韩国使用的汉字 [5]。
另外,对于日本人来说,假名等分配了0x3000~0x3FFF,共4096个字符。假名的字符很少,这个范围已经足够了,但是韩语一个字符的形状由初声、中声、终声的排列组合决定,初声19种,中声21种,终声27种,加上有些字符没有终声的情况,一共有19×21×(27+1)=11172个字符 [4]。这么多字符, Unicode当然适应不了了 [6]。
如此一来,结果就是在现有的16位Unicode上进行扩充(现在是21位),以16位为范围收录的一套字符作为UCS2(表示Universal Coded-Character Set的2位版)进行标准化,收录不下的所有字符以4字节表示,这就是UCS4。
5.2.2 Unicode的编码方式
如前所述,Unicode本来想用16位来表示世界上所有的字符(但实际上收录不下)。那么,在内存和磁盘中保存字符串的时候,只能2字节一组表示1个字符吗?也不一定。
我们首先介绍一下字符集(character set)和编码方式(正确的说法应该是字符符号化方式)。
计算机想要处理字符,首先要决定什么样的字符是要处理的对象,其次就是为这些字符分配编号,这就是字符集。为每个字符分配的编号在Unicode中称为码位(code point)。例如中文“啊”的码位是0xB0A1,记作U+B0A1。
但是,这些字符想要在内存或者磁盘上表示就是另外一码事了。编码方式是指规定以何种方式将逻辑上的码位值以字节或位的方式表示出来。虽然很可能某两种编码方式的目标字符集相同,但因为编码方式和字符在字符集中的顺序不同,因此在内存上的表现形式也不同。
Unicode的编码方式首先要考虑的是,Unicode是16位的,要为一个字符分配2字节,这种方法被称为 UTF-16 。
但是,假如使用C语言中2字节的整数类型(short等)表示1个字符,内存上的表示方式会根据环境的字节序产生变化。因此,UTF-16根据字节序的不同分为UTF-16BE(大尾序)和UTF-16LE(小尾序)两种。中文“啊”,用UTF-16BE表示为 0xB0 0xA1,用UTF-16LE表示为 0xA1 0xB0。另外,在和其他PC通信的时候,如果不知道字节序也很麻烦。可以在UTF-16的字符串开头加上字节序的标识(这种标识叫作BOM,即Byte Order Mark,值为U+FEFF)。读取带BOM的UTF-16字符串时,如果第一个字符是0xEF 0xFF就是UTF-16BE,如果是0xFF 0xEF就是UTF-16LE。
但是,UTF-16如果要表示字母A(码位为 U+0041)也要消耗2个字节,这样在英语圈的人(只使用ASCII的人)看来,字符串在内存和磁盘上的消耗突然变成了原来的2倍。而且,字符串ABC在内存上的表示方式(在UTF-16BE的情况下)为 0x00 0x41 0x00 0x42 0x00 0x43,并不兼容现存的ASCII编码。
为了解决上述问题,出现了 UTF-8 。首先,Unicode在0x00~0x7F的范围内分配了和ASCII编码相同的码位,由此,在UTF-8中上述范围的字符可以用1个字节表示,在这之后的0x80~0x7FF用2个字节的0xC280~0xDFBF表示, 0x800~0xFFFF用3个字节的0xE0A080~0xEFBFBF表示。用语言难以描述清楚,还是看图5-1吧。
图5-1 UTF-8的二进制表示方法
在图5-1中的“V”表示字符编码的位都存储在靠右的位置。这种方法的好处在于,在只使用ASCII字符的情况下兼容现存的ASCII编码,而且,由于ASCII字符以外的字符(UTF-8需要2字节以上来表示的字符)全部使用0x80以上的字节依次表示,因此即便只考虑了ASCII编码的程序(编译器等),(只要能通过8位)也可以正常地处理UTF-8的文件。像GB2312中0x5C那样的问题不会在UTF-8中出现。同样,在GB2312中有搜索“海”却被匹配成“ ""”的问题(因为“海”的GB2312编码为0xB0 0xA1,“ ""”的编码为0xA1 0xB0),UTF-8的第1个字节不会与其他字符的第2个以及之后的字节重复,因此不会有问题。另外,UTF-8的表示方式是以字节为单位的,所以也不会受字节序的影响 [7]。
UTF-8的缺点在于,用UTF-16的2个字节可以表示的字符,在UTF-8中需要3个字节才能表示。
话说回来,图5-1中所示,UTF-8最大为6字节,可以表示31位的码位(实际分配到了4字节21位)。但是,在使用UTF-16的情况下连表示21位的字符也做不到。因此需要使用一种称为代理对(Surrogate Pair)的方法,即如果最初的2个字节在特定范围(0xD800~0xDBFF)的话就要连接后面的2个字节来表示1个字符,以此方法表示0x10000~0x10FFFF之间的字符。0x110000以后不能用UTF-16表示。
补充知识 Unicode可以固定(字节)长度吗?
就像5.1.2节介绍的那样,在内存中表示某个字符串的时候,如果每个字符占的内存空间大小是可变的,就会很不方便。试想一下,只要写 str[i]就能取到 str的第i个字符的话确实很方便。
基于这点,在Unicode中UCS2范围内所有的字符都可以用2个字节表示,要是能忍受,即使ASCII字符也要消耗2个字节的话就太完美了。想法总是美好的,结果接下来发现2个字节容不下了,又引入了代理对,结果1个字符又失去了固定长度。真是蠢死了。肯定会有人这样想(实际上我以前就是这么想的)。
但是,Unicode 在最初的建议稿(1989 年 9 月的 Unicode Draft1)中就提出了,以在普通的罗马字后面加上方言记号的形式(合成字符)表示德语的元音变音及类似的字符。因此, 方言在Unicode中表示为 U+0041 U+0308(但是为了与现存的Latin-1编码兼容,也可以用 U+00C4表示)。
总之,Unicode从一开始就没有想让一个字符用固定长度表示。