说到GB2312和GBK就不得不提中文网页的编码。尽管很多新开发的Web系统和新上线的注重国际化的网站都开始使用UTF-8,仍有相当一部分的中文媒体坚持使用GB2312和GBK,例如新浪的页面。其中有两点很值得注意。
第一,页面中meta标签的部分,常常可以见到charset=GB2312这样的写法,很不幸的是,这个“charset”其实是用来指定页 面使用的是什么字符集编码,而不是使用什么字符集。例如你见到过有人写“charset=UTF-8”,见到过有人写“charset=ISO- 8859-1”,但你见过有人写“charset=Unicode”么?当然没有,因为Unicode是一个字符集,而不是编码。
然而正是charset这个名称误导了很多程序员,真的以为这里要指定的是字符集,也因而使他们进一步的误以为UTF-8和UTF-16是一种 字符集!(万恶啊)好在XML中已经做出了修改,这个位置改成了正确的名称:encoding.第二,页面中说的GB2312,实际上并不真的是 GB2312(惊讶么?)。我们来做个实验,例如找一个GB2312中不存在的汉字“亸”(这个字确实不在GB2312中,你可以到GB2312的码表中 去找,保证找不到),这个字在GBK中。然后你把它放到一个html页面中,试着在浏览器中打开它,然后选择浏览器的编码为“GB2312”,看到了什 么?它完全正常显示!
结论不用我说你也明白了,浏览器实际上使用的是GBK来显示。
新浪的页面中也有很多这样的例子,到处都写charset=GB2312,却使用了无数个GB2312中并不存在的字符。这种做法对浏览器显示 页面并不成问题,但在需要程序抓取页面并保存的时候带来了麻烦,程序将不能依据页面所“声称”的编码进行读取和保存,而只能尽量猜测正确的编码。
一个网页要想在浏览器中能够正确显示,需要在三个地方保持编码的一致:网页文件,网页编码声明和浏览器编码设置。
首先是网页文件本身的编码,即网页文件在被创建的时候使用什么编码来保存。这个完全取决于创建该网页的人员使用了什么编码保存,而进一步的取决 于该人员使用的操作系统。例如我们使用的中文版WindowsXP系统,当你新建一个文本文件,写入一些内容,并按下ctrl+s进行保存的那一刻,操作 系统就替你使用GBK编码将文件进行了保存(没有使用UTF-8,也没有使用UTF-16)。而使用了英文系统的人,系统会使用ISO-8859-1进行 保存,这也意味着,在英文系统的文件中如果输入一个汉字,是无法进行保存的(当然,你甚至都无法输入)。
一个在创建XML文件时(创建HTML的时候倒很少有人这么做)常见的误解是以为只要在页面的encoding部分声明了UTF-8,则文件就 会被保存为UTF-8格式。这实在是……怎么说呢,不能埋怨大家。实际上XML文件中encoding部分与HTML文件中的charset中一样,只是 告诉“别人”(这个别人可能是浏览你的页面的人,可能是浏览器,也可能是处理你页面的程序,别人需要知道这个,因为除非你告诉他们,否则谁也猜不出你用了 什么编码,仅通过文件的内容判断不出使用了什么编码,这是真的)这个文件使用了什么编码,唯独操作系统不会搭理,它仍然会按自己默认的编码方式保存文件 (再一次的,在我们的中文WindowsXP系统中,使用GBK保存)。至于这个文件是不是真的是encoding或者charset所声明的那种编码保 存的呢?答案是不一定!
例如新浪的页面就“声称”他是用GB2312编码保存的,但实际上却是GBK,也有无数的二把刀程序员用系统默认的GBK保存了他们的XML文件,却在他们的encoding中信誓旦旦的说是UTF-8的。
这就是我们所说的第二个位置,网页编码声明中的编码应该与网页文件保存时使用的编码一致。
而浏览器的编码设置实际上并不严格,就像我们第三节所说的那样,在浏览器中选择使用GB2312来查看,它实际上仍然会使用GBK进行。而且浏览器还有这样一种好习惯,即它会尽量猜测使用什么编码查看最合适。
我要重申的是,网页文件的编码和网页文件中声明的编码保持一致,这是一个极好的建议(值得遵循,会与人方便,与己方便),但如果不一致,只要网页文件的编码与浏览器的编码设置一致,也是可以正确显示的。
例如有这样一个页面,它使用GBK保存,但声明自己是UTF-8的。这个时候用浏览器打开它,首先会看到乱码,因为这个页面“告诉”浏览器用UTF-8显示,浏览器会很尊重这个提示,于是乱码一片。但当手工把浏览器设为GBK之后,显示正常。
说了以上这么多,后面我们就来侃侃Java里的字符编码,你会发现有意思且挠头的事情很多,但一旦弄通,天下无敌(不过不要像东方不败那样才好)。 如果你是JVM的设计者,让你来决定JVM中所有字符的表示形式,你会不会允许使用各种编码方式的字符并存?
我想你的答案是不会,如果在内存中的Java字符可以以GB2312,UTF-16,BIG5等各种编码形式存在,那么对开发者来说,连进行最 基本的字符串打印、连接等操作都会寸步难行。例如一个GB2312的字符串后面连接一个UTF-8的字符串,那么连接后的最终结果应该是什么编码的呢?你 选哪一个都没有道理。
因此牢记下面这句话,这也是Java开发者的共同意志:在Java中,字符只以一种形式存在,那就是Unicode(注意到我们没有选择特定的编码,直接使用它们在字符集中的编号,这是统一的唯一方法)。
但“在Java中”到底是指在哪里呢?就是指在JVM中,在内存中,在你的代码里声明的每一个char,String类型的变量中。例如你在程序中这样写
char han='汉';
在内存的相应区域,这个字符就表示为0x6C49.可以用下面的代码证明一下:
char han='汉';
System.out.format("%x",(short)han);
输出是:6c49反过来用Unicode编号来指定一个字符也可以,像这样:
char han=0x6c49;
System.out.println(han);
输出是:汉
这其实也是说,只要你正确的读入了“汉”这个字,那么它在内存中的表示形式一定是0x6C49,没有任何其他的值能代表这个字(当然,如果你读错了,那结果是什么就不知道了,范伟说:读,读错了呀,那还等于好几亿呢;本山大哥说:好几亿你也没答上,请听下一题)。
JVM的这种约定使得一个字符存在的世界分为了两部分:JVM内部和OS的文件系统。在JVM内部,统一使用Unicode表示,当这个字符被 从JVM内部移到外部(即保存为文件系统中的一个文件的内容时),就进行了编码转换,使用了具体的编码方案(也有一种很特殊的情况,使得在JVM内部也需 要转换,不过这个是后话)。
因此可以说,所有的编码转换就只发生在边界的地方,JVM和OS的交界处,也就是你的各种输入输出流(或者Reader,Writer类)起作用的地方。
话头扯到这里就必须接着说Java的IO系统。
尽管看上去混乱繁杂,但是所有的IO基本上可以分为两大阵营:面向字符的Reader啊Wrtier啊,以及面向字节的输入输出流。
下面我来逐一分解,其实一点也不难。
面向字符和面向字节中的所谓“面向”什么,是指这些类在处理输入输出的时候,在哪个意义上保持一致。如果面向字节,那么这类工作要保证系统中的 文件二进制内容和读入JVM内部的二进制内容要一致。不能变换任何0和1的顺序。因此这是一种非常“忠实于原著”的做法(偶然间让我想起郭敬明抄袭庄羽的 文章,那家伙,太忠实于原著了,笑)。
这种输入输出方式很适合读入视频文件或者音频文件,或者任何不需要做变换的文件内容。
而面向字符的IO是指希望系统中的文件的字符和读入内存的“字符”(注意和字节的区别)要一致。例如我们的中文版WindowsXP系统上有一 个GBK的文本文件,其中有一个“汉”字,这个字的GBK编码是0xBABA(而Unicode编号是0x6C49),当我们使用面向字符的IO把它读入 内存并保存在一个char型变量中时,我希望IO系统不要傻傻的直接把0xBABA放到这个char型变量中,我甚至都不关心这个char型变量具体的二 进制内容到底是多少,我只希望这个字符读进来之后仍然是“汉”这个字。
从这个意义上也可以看出,面向字符的IO类,也就是Reader和Writer类,实际上隐式的为我们做了编码转换,在输出时,将内存中的 Unicode字符使用系统默认的编码方式进行了编码,而在输入时,将文件系统中已经编码过的字符使用默认编码方案进行了还原。我两次提到“默认”,是说 Reader和Writer的聪明也仅此而已了,它们只会使用这个默认的编码来做转换,你不能为一个Reader或者Writer指定转换时使用的编码。 这也意味着,如果你使用中文版WindowsXP系统,而上面存放了一个UTF-8编码的文件,当你使用Reader类来读入的时候,它会傻傻的使用 GBK来做转换,转换后的内容当然驴唇不对马嘴!
这种笨,有时候其实是一种傻瓜式的功能提供方式,对大多数初级用户(以及不需要跨平台的高级用户)来说反而是件好事。
但我们不一样啦,我们都是国家栋梁,肩负着赶英超美的责任,必须师夷长技以治夷,所以我们总还要和GBK编码以外的文件打交道。
说了上面这些内容,想必聪明的读者已经看出来,所谓编码转换就是一个字符与字节之间的转换,因此Java的IO系统中能够指定转换编码的地方, 也就在字符与字节转换的地方,那就是(读者:InputStreamReader和OutputStreamWriter!作者:太强了,都会抢答了!)
这两个类是字节流和字符流之间的适配器类,因此他们肩负着编码转换的任务简直太自然啦!要注意,实际上也只能在这两类实例化的时候指定编码,是不是很好记呢?
下面来写一段小程序,来把“汉”字用我们非常崇拜的UTF-8编码写到文件中!
try{
PrintWriter out=new PrintWriter(new OutputStreamWriter(new FileOutputStream("c:/utf-8.txt"),"UTF-8"));
out.write("汉"); out.close();
}catch(IOException e){throw new RuntimeException(e);}
运行之后到c盘下去找utf-8.txt这个文件,用UltraEdit打开,使用16进制查看,看到了什么?它的值是0xE6B189!噢耶!(读者:这,这有什么好高兴的……)
Java号称对Unicode提供天然的支持,这话在很久很久以前就已经是假的了(不过曾经是真的),实际上,到JDK5.0为止,Java才算刚刚跟上Unicode的脚步,开始提供对增补字符的支持。
现在的Unicode码空间为 U+0000到U+10FFFF,一共1114112个码位,其中只有1,112,064 个码位是合法的(我来替你做算术,有2048个码位不合法),但并不是说现在的Unicode就有这么多个字符了,实际上其中很多码位还是空闲的,到 Unicode 4.0 规范为止,只有96,382个码位被分配了字符(但无论如何,仍比很多人认为的65536个字符要多得多了)。其中U+0000 到U+FFFF的部分被称为基本多语言面(Basic Multilingual Plane,BMP)。U+10000及以上的字符称为补充字符。在Java中(Java1.5之后),补充字符使用两个char型变量来表示,这两个 char型变量就组成了所谓的surrogate pair(在底层实际上是使用一个int进行表示的)。第一个char型变量的范围称为“高代理部分”(high-surrogates range,从"uD800到"uDBFF,共1024个码位), 第二个char型变量的范围称为low-surrogates range(从"uDC00到"uDFFF,共1024个码位),这样使用surrogate pair可以表示的字符数一共是1024的平方计1048576个,加上BMP的65536个码位,去掉2048个非法的码位,正好是1,112,064 个码位。
关于Unicode的码空间实 际上有一些稍不小心就会让人犯错的地方。比如我们都知道从U+0000到U+FFFF的部分被称为基本多语言面(Basic Multilingual Plane,BMP),这个范围内的字符在使用UTF-16编码时,只需要一个char型变量就可以保存。仔细看看这个范围,应该有65536这么大,因 此你会说单字节的UTF-16编码能够表示65536个字符,你也会说Unicode的基本多语言面包含65536个字符,但是再想想刚才说过的 surrogate pair,一个UTF-16表示的增补字符(再一次的,需要两个char型变量才能表示的字符)怎样才能被正确的识别为增补字符,而不是两个普通的字符 呢?答案你也知道,就是通过看它的第一个char是不是在高代理范围内,第二个char是不是在低代理范围内来决定,这也意味着,高代理和低代理所占的共 2048个码位(从0xD800到0xDFFF)是不能分配给其他字符的。
但这是对UTF-16这种编码方法而言,而对Unicode这样的字符集呢?在Unicode的编号中,U+D800到U+DFFF是否有字符 分配?答案是也没有!这是典型的字符集为方便编码方法而做的安排(你问他们这么做的目的?当然是希望基本多语言面中的字符和一个char型的UTF-16 编码的字符能够一一对应,少些麻烦,从中我们也能看出UTF-16与Unicode间很深的渊源与结合)。也就是说,无论Unicode还是UTF-16 编码后的字符,在0x0000至0xFFFF这个范围内,只有63488个字符(65536除去2048)。这就好比最初的CPU被勉强拿来做多媒体应用,用得多了,CPU就不得不 修正自己从硬件上对多媒体应用提供支持了。
尽管不情愿,但说到这里总还得扯扯相关的概念:代码点和代码单元。
代码点(Code Point)就是指Unicode中为字符分配的编号,一个字符只占一个代码点,例如我们说到字符“汉”,它的代码点是U+6C49.代码单元(Code Unit)则是针对编码方法而言,它指的是编码方法中对一个字符编码以后所占的最小存储单元。例如UTF-8中,代码单元是一个字节,因为一个字符可以被 编码为1个,2个或者3个4个字节;在UTF-16中,代码单元变成了两个字节(就是一个char),因为一个字符可以被编码为1个或2个char(你找 不到比一个char还小的UTF-16编码的字符,嘿嘿)。说得再罗嗦一点,一个字符,仅仅对应一个代码点,但却可能有多个代码单元(即可能被编码为2个 char)。
以上概念绝非学术化的绕口令,这意味着当你想以一种统一的方式指定自己使用什么字符的时候,使用代码点(即你告诉你的程序,你要用 Unicode中的第几个字符)总是比使用代码单元更好(因为这样做的话你还得区分情况,有时候提供一个16进制数字,有时候要提供两个)。
例如我们有一个增补字符???(哈哈,你看到了三个问号对吧?因为我的系统显示不出这个字符),它在Unicode中的编号是U+2F81A,当在程序中需要使用这个字符的时候,就可以这样来写:
String s=String.valueOf(Character.toChars(0x2F81A));
char[]chars=s.toCharArray();
for(char c:chars){
System.out.format("%x",(short)c);
}
后面的for循环把这个字符的UTF-16编码打印了出来,结果是d87edc1a注意到了吗?这个字符变成了两个char型变量,其中0xd87e就是高代理部分的值,0xdc1a就是低代理的值。