字符编码的那些事——原来C#、Java的一个char并不是对应一个显示字符

  很久很久以前,当我想用 C 语言处理中文时,遇到了一些麻烦:C 语言中的 char 只占用 1 字节,但是 GBK 编码的汉字会占用两个字节。如果直接使用 char,会遇到一些非常神奇的问题,比如“页苑估”字符串中含有“吃饭”子串[1]。处理 GBK 编码其实也挺简单,判断一下,如果发现某个 char 的最高位是1,就和下一个 char 合起来考虑。偷懒的办法就是预处理一遍,把合并后的结果存到 short int(其实还有 wchar_t 这种专用的宽字符变量类型,使用时需要注意在 Windows 下是2字节,而在 Linux 下是4字节) 里,这样每个变量就可以表示一个字符了。

  当我知道 C#、Java 的 char 是一个 16 位的存储单元时,我就开始天真的以为“终于一个 char 可以表示一个汉字了”。虽然之后也听说了 C# 和 Java 中的 char 是以 Unicode 的形式存储的(确切的说,是 UTF-16),但是对一个 char 表示一个字符的观念,一直没有改变,直到昨天膝盖中了一箭……

  我在处理维基百科语料时,想统计一下里面出现了多少种不同的字符。随手就写了一个逐 char 的循环,统计之后输出 <char, 出现次数> 的二元组。 结果写文件的时候,抛出了这个异常:

未经处理的异常: System.Text.EncoderFallbackException: 无法将位于索引 551 处的 Unicode 字符 \uD86A 转换为指定的代码页。

  搜索良久,没发现有人遇到这个问题。只好开始猜想,是不是有些字符能用 UTF-16 表示,但不能用 UTF-8 表示?又或许有些 char 不能独立存在?

  第二个猜想在 Unicode 的官网找到了答案[2],同时也否定了第一个猜想:

The Unicode Standard encodes characters in the range U+0000..U+10FFFF, which amounts to a 21-bit code space. Depending on the encoding form you choose (UTF-8, UTF-16, or UTF-32), each character will then be represented either as a sequence of one to four 8-bit bytes, one or two 16-bit code units, or a single 32-bit code unit.

  里面涉及了两个要点。1. Unicode 编码的范围是固定的,Unicode 字符可以选用 UTF-8、UTF-16、UTF-32 这些编码方式来存储,也就是说这三种编码方式可以无损转换。2. 一个 Unicode 字符,在使用 UTF-16 编码方式存储时,会使用1个或两个码元(code unit),也就是 C# 里面的 char。

  于是有些Unicode字符,在 UTF-16 编码方式下,需要用两个码元来表示。在维基百科里找到一个例子:“𪚥”。这个字就需要两个码元来表示。UTF-16 把表示这种字的两个码元称作“代理对(surrogate pair)”,代理对由高位代理和低位代理组合而成,下面的 C# 代码展示了这个分解过程。

var str = "𪚥";
Console.WriteLine(str.Length);
Console.WriteLine(Char.IsHighSurrogate(str[0]));
Console.WriteLine(Char.IsLowSurrogate(str[1]));

  那是不是一个 Unicode 字符就是一个显示字符呢?答案居然是“否”!

  世界上还有“组合字符[4]”这种神一般的存在,最常见的一个肯定很多人都见过“ส้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้”。用来破坏排版的神器。这真的是一个显示字符,不信的话,你可以把它复制下来粘贴到 Word 里,看看是不是占了一个字符的位置。当然,一些比较弱的软件可能不能正常显示这个字符。组合字符一般是用来给拉丁文加重音符号等附加符号的,比如 Ä = A + ¨,也可以在一个字符后面加入一堆附加符号,形成刚才那个神器的效果。

  当然,C#这样的高级语言处理显示字符也是比较方便的,使用 StringInfo [5]类即可,比如下面的代码会输出 39、1。想要逐字符提取可以用 StringInfo 类中的其它方法,非常方便。Java 中应该也有类似的方法,没去调研。

var str = "ส้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้";
Console.WriteLine(str.Length);
StringInfo si = new StringInfo(str);
Console.WriteLine(si.LengthInTextElements);

  最后,在@trouger 的帮助下,我们发现 UTF-8 编码[6]在多字节时,各字节的首位均是 1。利用这一点,如果我想用 C 语言写一个简单的程序,把 UTF-8 编码的文件按照空格分割(或者任意ASCII字符),直接使用 C 语言的 char 就可以完全胜任,无需考虑复杂的编码方式。

[1] 百度之星2007程序设计大赛 两场初赛题目点评 http://www.baiduer.com.cn/2007-06/1207.html
[2] UTF-8, UTF-16, UTF-32 & BOM http://www.unicode.org/faq/utf_bom.html
[3] 码元 – 维基百科 http://zh.wikipedia.org/wiki/码元
[4] 组合字符 – 维基百科 http://zh.wikipedia.org/wiki/组合字符
[5] StringInfo Class http://msdn.microsoft.com/en-us/library/system.globalization.stringinfo.aspx
[6] UTF-8 – 维基百科 http://zh.wikipedia.org/wiki/UTF-8

8 评论

  1. 没怎么看明白这篇博文,希望楼主可以细致的讲一下utf16,gbk,utf8等编码是不是变长编码,相互之间的转关关系,以及对于汉字是统一长度还是变长?

    1. GBK表示ASCII字符是1字节,汉字是2字节。
      UTF-16常用的字都是2字节,少数字是4字节的。
      UTF-8是变长的,1-4字节(理论上可以到6字节),1字节的就是ASCII字符,一般的汉字是3字节

      至于组合字符,貌似汉字没这种情况吧。

  2. 感谢楼主的博文,看完涨了不少姿势。
    这里有两点问题:1.现在已知一个可见字符不一定等于一个码元,那么不同编码的情况下,是否一个可见字符会等于不同的码元数呢?比如”***”在utf-8下是6个字符,在utf-16下是4个字符。
    2.文中最后一段话不太明白,“我们发现 UTF-8 编码[6]在多字节时,各字节的首位均是 1”,这里的“各字节”是不是表述有误呢?

    1. 1. 维基百科是这么解释的:码元(Code Unit,也称“代码单元”)是指一个已编码的文本中具有最短的比特组合的单元。对于UTF-8来说,码元是8比特长;对于UTF-16来说,码元是16比特长;对于UTF-32来说,码元是32比特长。
      2. 自我感觉没写错……

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注