Thursday, May 08, 2008

关于字符(characters),字符集(character sets), 编码(encodings)

在程序中我们经常要处理字符的编码转换,比如两个系统之间传输包含中文字符的信息时候,或者向数据库存储/读取中文数据的时候,你都会碰到这个问题。对于缺乏经验的程序员,这是个烦恼和郁闷的问题。 起码我是经受过这样的反复折磨,每个项目都会碰到这个问题,而每次解决了之后还是觉得若有所示,因为我不清楚为什么这样就解决了,虽然我一直是这样解决这个问题的。 所以我期望把这个问题搞清楚,我希望知道为什么错,有为什么对。。。。

[字符,字符集,编码]

字符, 呵呵,就是字符,比如a,b,c,或者汉字的'我'.
字符集(比如unicode)定义了一种编码规范支持哪些字符,每个字符都会被分配一个编码(或者说整数,以免和后面要说的编码混淆,后面说的编码应该是一个动词),通过这个编码就可以映射到相应的字符. 在不同的字符集中,同一个字符的编码一般是不一样的.
而编码(比如utf-8)是定义了如何将这些字符保存在内存或者文件等。 GBK字符集包括2万多个汉字,每个字符都有一个码,编码就是如何将这些码保存下来。 unicode有些不同,因为unicode为每个字符定义了code point,编码标准,比如utf-8, ucs-2,定义了如何将code point保存下来。

[存在的编码方法]

这里说的编码方式不仅仅只中文,还包括其他,相信俄文,印度文等等都会有自己的类似gbk一类的编码标准。下面只列出我知道的,比较常用的编码方式:
  • ASCII: American Standard Code for Information Interchange, 这是英文字符的编码,使用了单字节,ASCII的最高bit位都为0,从0-127。
    ASCII码是7位编码,编码范围是0x00-0x7F。ASCII字符集包括英文字母、阿拉伯数字和标点符号等字符。其中0x00-0x20和0x7F共33个控制字符。

    只支持ASCII码的系统会忽略每个字节的最高位,只认为低7位是有效位。HZ字符编码就是早期为了在只支持7位ASCII系统中传输中文而设计的编码。早期很多邮件系统也只支持ASCII编码,为了传输中文邮件必须使用BASE64或者其他编码方式。
  • Latin-1(ISO-8859-1): 这是欧洲的编码,因为西欧语言中包含很多字符,比如á这种字符。它是ASCII的超集,那些古怪字符用的是128-255的范围,利用单字节存储。
  • Unicode(www.unicode.org):这是一个想要大一统的编码标准,要为世界上每种语言的每个字符都定义一个编码。它又是Latin-1的超集,用两个字节存储。当然不可能是所有字符,其实是语言的常用字符,比如汉字编码的范围是4e00-9fcf,大概2万个左右,不会包含古老的甲骨文汉字。 unicode是一个字符集,它有许多不同的编码方式,比如UCS-2是直接用两个自己来存储,而utf-16也是用两个自己存储,而utf-8是变长的,对0-127之间的用单个字节,128以上的用两个,三个甚至6个字节来表示。(CHECK:??字节的最高位第八位用来表示这个字符是单字节还是双字节
  • GBK:这是中国定义的汉字编码标准,最早是GB2312,只定义了常用汉字,大概2万个左右(unicode主要包含gb2312中的汉字),后来的gbk是对gb2312的扩展,加入了很多不常用的汉字,同时也支持了繁体字。中文字符的每个字节的最高位都为1。 在中国大陆使用的计算机上,它们的本地编码(ANSI编码)大多都是gbk。在一个国家的本地化系统中出现的一个字符,通过电子邮件传送到另外一个国家的本地化系统中,看到的就不是那个字符了,而是另个那个国家的一个字符或乱码。(http://www.btinternet.com/~jlonline/back/GBK.htm)
    GB2312是基于区位码设计的,区位码把编码表分为94个区,每个区对应94个位,每个字符的区号和位号组合起来就是该汉字的区位码。区位码一般 用10进制数来表示,如1601就表示16区1位,对应的字符是“啊”。在区位码的区号和位号上分别加上0xA0就得到了GB2312编码。

  • UTF-8: 首先 UCS 和 Unicode 只是分配整数给字符的编码表. 现在存在好几种将一串字符表示为一串字节的方法. 最显而易见的两种方法是将 Unicode 文本存储为 2 个 或 4 个字节序列的串. 这两种方法的正式名称分别为 UCS-2 和 UCS-4. 除非另外指定, 否则大多数的字节都是这样的(Bigendian convention). 将一个 ASCII 或 Latin-1 的文件转换成 UCS-2 只需简单地在每个 ASCII 字节前插入 0x00. 如果要转换成 UCS-4, 则必须在每个 ASCII 字节前插入三个 0x00.

    在 Unix 下使用 UCS-2 (或 UCS-4) 会导致非常严重的问题. 用这些编码的字符串会包含一些特殊的字符, 比如 '\0' 或 '/', 它们在 文件名和其他 C 库函数参数里都有特别的含义. 另外, 大多数使用 ASCII 文件的 UNIX 下的工具, 如果不进行重大修改是无法读取 16 位的字符的. 基于这些原因, 在文件名, 文本文件, 环境变量等地方, UCS-2 不适合作为 Unicode 的外部编码.

    在 ISO 10646-1 Annex R 和 RFC 2279 里定义的 UTF-8 编码没有这些问题. 它是在 Unix 风格的操作系统下使用 Unicode 的明显的方法.

    UTF-8 有以下特性:

    *UCS 字符 U+0000 到 U+007F (ASCII) 被编码为字节 0x00 到 0x7F (ASCII 兼容). 这意味着只包含 7 位 ASCII 字符的文件在 ASCII 和 UTF-8 两种编码方式下是一样的.
    *所有 >U+007F 的 UCS 字符被编码为一个多个字节的串, 每个字节都有标记位集. 因此, ASCII 字节 (0x00-0x7F) 不可能作为任何其他字符的一部分.
    *表示非 ASCII 字符的多字节串的第一个字节总是在 0xC0 到 0xFD 的范围里, 并指出这个字符包含多少个字节. 多字节串的其余字节都在 0x80 到 0xBF 范围里. 这使得重新同步非常容易, 并使编码无国界, 且很少受丢失字节的影响.
    *可以编入所有可能的 231个 UCS 代码
    *UTF-8 编码字符理论上可以最多到 6 个字节长, 然而 16 位 BMP 字符最多只用到 3 字节长.
    *Bigendian UCS-4 字节串的排列顺序是预定的.
    *字节 0xFE 和 0xFF 在 UTF-8 编码中从未用到.

    下列字节串用来表示一个字符. 用到哪个串取决于该字符在 Unicode 中的序号.
    U-00000000 - U-0000007F: 0xxxxxxx
    U-00000080 - U-000007FF: 110xxxxx 10xxxxxx
    U-00000800 - U-0000FFFF: 1110xxxx 10xxxxxx 10xxxxxx
    U-00010000 - U-001FFFFF: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
    U-00200000 - U-03FFFFFF: 111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx
    U-04000000 - U-7FFFFFFF: 1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx

    xxx 的位置由字符编码数的二进制表示的位填入. 越靠右的 x 具有越少的特殊意义. 只用最短的那个足够表达一个字符编码数的多字节串. 注意在多字节串中, 第一个字节的开头"1"的数目就是整个串中字节的数目.

    例如: Unicode 字符 U+00A9 = 1010 1001 (版权符号) 在 UTF-8 里的编码为:

    11000010 10101001 = 0xC2 0xA9

    而字符 U+2260 = 0010 0010 0110 0000 (不等于) 编码为:

    11100010 10001001 10100000 = 0xE2 0x89 0xA0

    这种编码的官方名字拼写为 UTF-8, 其中 UTF 代表 UCS Transformation Format. 请勿在任何文档中用其他名字 (比如 utf8 或 UTF_8) 来表示 UTF-8, 当然除非你指的是一个变量名而不是这种编码本身.

[编码转换]
实际上不存在什么编码转换地概念, 当你用什么编码方式写出的时候, 读入就应该用同样的编码方式,否则乱码就来找你了.先看下面一个例子:


String input = "S茅n茅gal";
System.out.println(HexCoder.bufferToHex(input.getBytes("GBK")));
>>53c3a96ec3a967616c
System.out.println(HexCoder.bufferToHex(input.getBytes("UTF-8")));
>>53e88c856ee88c8567616c
String utf8 = new String(input.getBytes("GBK"), "UTF-8");
System.out.println(utf8);
>>Sénégal

可以看到GBK和UTF8得到的字节数组是不一样的,有趣的是最有的变量'utf8',它的内容是法文'Sénégal', 而初始的变量'input'才是乱码.这个转换其实容易理解, input.getBytes('GBK')会得到'53c3a96ec3a967616c', 而用utf-8的方式来解码的话,就得到了'Sénégal'.这个例子让我们觉得乱码和原文之间可以互相转换, 某种程度上说是这样的.
但是, 再看看下面的例子:


String input = "我";
System.out.println(HexCoder.bufferToHex(input.getBytes("GBK")));
>>ced2
System.out.println(HexCoder.bufferToHex(input.getBytes("UTF-8")));
>>e68891
String utf8 = new String(input.getBytes("GBK"), "UTF-8");
System.out.println(utf8);
>>??
System.out.println(HexCoder.bufferToHex(utf8.getBytes("GBK")));
>>3f3f
System.out.println(HexCoder.bufferToHex(utf8.getBytes("UTF-16")));
>>fefffffdfffd
* feff is Unicode Byte Order Mark.

这个例子和第一个例子只有变量input的值不同, 但是可以看到变量utf8.getBytes("GBK")最后打印出来是??, 为什么? input.getBytes("GBK")后得到了ced2, 当用UTF8来解码的时候,utf8完全无法识别这个编码,所以用?(\ufffd, 不是问号\u003f)表示. 而最后一句'utf8.getBytes("GBK")', 打印出来的也就是3f3f(3f就是ascii中的?, 因为gbk的字符集中没有和\ufffd对应的字符)了.

还有一个问题,java中的字串是unicode, 这句话到底什么意思? 看下面的例子:
System.out.println("\u0048\u0065\u006C\u006C\u006F");
>>Hello
这里0048是字符H的unicode的code point,和编码(就是如何保存这个字串在内存/磁盘或者在网络中传输)没有关系.

It does not make sense to have a string without knowing what encoding it uses
[内码外码]
所谓内码就是指的gbk,unicode的编码,而外码指的是输入法中定义的字码,外码是可以随便定义,而内码是不能变的,输入法会映射外码到内码。

这里还是要强调需要搞清楚字符,字符集,编码的区别, 可以认为GBK的字符集和编码定义的值都是一样的.
看看在java中的表现.
char c = '甜';
System.out.println(Integer.toHexString((int)c));
// print out: 751c, 打印出来'甜'的unicode的codepoint(就是说在jvm内部是以codepoint(unicode字符集定义了每个字符的codepoint)来表示一个uncode字符。但是当需要和其他系统交换这个字符的时候,就需要采用编码规则来将字符编码成字节), 这
//也说明为什么我们总是说java中的字符都是unicode的. 也就是说,JVM内存中, 所有的字符都是以unicode的UTF16编码形式表示的
byte[] output = src.getBytes("UTF-8");
for (byte o : output){
System.out.println(Integer.toHexString(o));
}
// print out:
// ffffffe7
// ffffff94
// ffffff9c

output = src.getBytes("GBK");
for (byte o : output){
System.out.println(Integer.toHexString(o));
}
// print out:
// ffffffcc
// fffffff0
// 为什么能得GBK的编码值呢? java提供了一个CharsetDecoder的类用来执行这种转换(每一个charset都会有一个
// 对应的子类), 估计应该是首先从UTF16的编码得到unicode字符集的code point, 然后在unicode的字符集和
// gbk的字符集之间有一个映射(估计操作系统会管理这个映射,或者JVM自己管理,这个不是重点),最后对这个GBK的code point进行GBK编码.
// 就是说,字符集是不同编码之间进行转换的桥梁.

output = src.getBytes("UTF-16");
for (byte o : output){
System.out.println(Integer.toHexString(o));
}
// print out:
// fffffffe
// ffffffff
// 75
// 1c
// Java2平台内对unicode采用UTF16的编码方式, 这种编码方式GBK的特点, 编码出来的值和code point是一样的
// (只是指unicode的BMP部分:U+0000 - U+FFFF). 对大于U+FFFF的字符会用四个字节来进行编码,具体的可以参考
// UTF16编码规范. 为什么'tian'在utf16编码后会有四个字节呢????

更多信息可以参考:
  • 字符,字节和编码:http://www.regexlab.com/zh/encoding.htm
  • GBK, http://www.btinternet.com/~jlonline/back/GBK.htm
  • 字符编码笔记(

    http://www.ruanyifeng.com/blog/2007/10/ascii_unicode_and_utf-8.html)
  • The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets (http://www.joelonsoftware.com/articles/Unicode.html)

No comments: