Unicode的三三两两

Author Avatar
Magicmanoooo 3月 09, 2019
  • 在其它设备中阅读本文章

Unicode

对于一般的ASCII,它是一种字符编码,把128个字符映射到整数:0~127。这种7-bit字符编码系统比较简单,在计算机中以一个字节存储一个字符,但是它仅仅适用于美国英语,甚至连一些英语中常见的标点符号等都不能表示,更别谈其他各国语言。

在Unicode出现之前,个个地区都根据自己的情况制定了不同的编码系统,比如中文主要使用GB 2312和BIG 5,日文主要使用JIS等。但是这样造成了诸多不便,例如一个文本信息很难混合各种不同语言的文字。

因此,在上世纪80年代末,Xerox、Apple 等公司开始研究,是否能制定一套多语言的统一编码系统。后来,多个机构成立了 Unicode 联盟,在 1991 年释出Unicode 1.0,收录了24种语言共7161个字符。在四分之一个世纪后的2016年,Unicode已释出9.0版本,收录135种语言共128237个字符。

这些字符被收录为同一字符集(Universal Coded Character Set,UCS)。每个字符映射到一个整数码点(code point),码点的范围是0~0x10FFFF,码点通常又记作\U+XXXX,其中XXXX为16进制数字。例如,张—>\U5F20、涛—>\U6D9B。显然,UCS中的字符不能像ASCII那样以一个字节进行存储。

所以,Unicode还制定了各种存储码点的方式,这些方式被称为Unicode转换格式(Uniform Transformation Format,UTF)。现在较为流行的UTF为UTF-8、UTF-16和UTF-32。每种UTF会把一个码点储存为一至多个编码单元(code unit)。例如UTF-8的编码单元是8位字节、UTF-16为16位、UTF-32为32位。除UTF-32外,UTF-8和UTF-16都是可变长度编码。

UTF-8成为现时互联网上最流行的格式的原因:

  • 它采用字节为编码单元,不会有字节序(endianness)的问题。
  • 每个ASCII字符只需一个字节去储存。
  • 如果程序原来是以字节方式储存字符,理论上不需要特别改动就能处理

Unicode和UTF-8之间的转换

UTF-8的编码单元是8位字节,每个码点编码成1至4个字节。它的编码方式很简单,按照码点的范围,把码点的二进位分拆成1至最多4个字节:

这个编码方法的好处之一是,码点范围U+0000~U+007F编码为一个字节,与ASCII编码兼容。这范围的Unicode码点也是和ASCII字符相同的。因此,一个ASCII文本也是一个UTF-8文本。

例如,解析多字节的情况,欧元符号€→U+20AC

  1. U+20ACU+0800~U+FFFF的范围内,应编码成3个字节。
  2. U+20AC的二进位为10 0000 1010 1100
  3. 3个字节的情况需要要16位的码点,所以在前面补两个0,成为0010 0000 1010 1100
  4. 按上表把二进制分成3组:0010000010101100
  5. 加上每个字节的前缀:111000101000001010101100
  6. 用十六进位表示即:0xE20x820xAC

对于上述例子,其对应的代码为:

if (u >= 0x0800 && u <= 0xFFFF) {
    OutputByte(0xE0 | ((u >> 12) & 0xFF)); /* 0xE0 = 11100000 */
    OutputByte(0x80 | ((u >>  6) & 0x3F)); /* 0x80 = 10000000 */
    OutputByte(0x80 | ( u        & 0x3F)); /* 0x3F = 00111111 */
}

对于非转义(unescaped)的字符,只要它们的ASCII码值不少于32(0 ~ 31是不合法的编码单元)。

而对于JSON字符串中的\uXXXX是以16进制表示码点U+0000~U+FFFF,我们需要:

  1. 将4位十六进制整数解析为码点
  2. 由于字符串是以UTF-8存储,所以要把这个码点编码成UTF-8

不难发现,4位的16进制数字只能表示0~0xFFFF,但UCS的码点是从0~0x10FFFF,那怎么能表示多出来的码点呢?

可以将Unicode理解为一本很厚的书籍,它将世界上所有的字符都定义在一个集合之中。但这么多的字符并不是一次性定义的,而是分区定义的。每个区可以存放65535($2^{16}$)个字符,将这个区称为一个平面(plane)。目前,一共有17(共有$2^{5}$)平面,也就是整个Unicode字符集的大小是$2^{21}$。

最前面的65536个字符位,称为基本平面(简称BMP),它的码点范围是从0~$2^{16}$−1,写成16进制就是从U+0000~U+FFFF。所有最常见的字符都放在这个平面,这是Unicode最先定义和公布的一个平面。剩下的字符都放在辅助平面(简称SMP),码点范围从U+010000~U+10FFFF

其实,U+0000~U+FFFF这组Unicode字符称为基本多文种平面(basic multilingual plane, BMP),还有另外16个平面。那么BMP以外的字符,JSON会使用代理对(surrogate pair)表示\uXXXX\uYYYY。在 BMP中,保留了2048个代理码点。如果第一个码点是U+D800~U+DBFF,我们便知道它的代码对的高代理项(high surrogate),之后应该伴随一个U+DC00~U+DFFF低代理项(low surrogate)。计算步骤为:

  • 码位减去0x10000,得到的值的范围为20比特长的0~0xFFFFF
  • 高位的10比特的值(值的范围为0~0x3FF)被加上0xD800得到第一个码元或称作高位代理(high surrogate),值的范围是0xD800~0xDBFF。由于高位代理比低位代理的值要小,所以为了避免混淆使用,Unicode标准现在称高位代理为前导代理(lead surrogates)
  • 低位的10比特的值(值的范围也是0~0x3FF)被加上0xDC00得到第二个码元或称作低位代理(low surrogate),现在值的范围是0xDC00~0xDFFF。由于低位代理比高位代理的值要大,所以为了避免混淆使用,Unicode标准现在称低位代理为后尾代理(trail surrogates)。

然后,我们用下列公式把代理对(H, L)变换成真实的码点:

codepoint = 0x10000 + (H − 0xD800) × 0x400 + (L − 0xDC00)

举个例子,高音谱号字符?→U+1D11E不是BMP之内的字符。在JSON中可写成转义序列\uD834\uDD1E

  • 先解析第一个\uD834得到码点U+D834,发现它是U+D800~U+DBFF内的码点,所以它是高代理项。
  • 然后解析下一个转义序列\uDD1E得到码点U+DD1E,它在U+DC00~U+DFFF 之内,是合法的低代理项。

我们计算其码点:

H = 0xD834, L = 0xDD1E
codepoint = 0x10000 + (H − 0xD800) × 0x400 + (L − 0xDC00)
          = 0x10000 + (0xD834 - 0xD800) × 0x400 + (0xDD1E − 0xDC00)
          = 0x10000 + 0x34 × 0x400 + 0x11E
          = 0x10000 + 0xD000 + 0x11E
          = 0x1D11E

这样就得出这转义序列的码点,然后我们再把它编码成 UTF-8。如果只有高代理项而欠缺低代理项,或是低代理项不在合法码点范围,我们都返回 LEPT_PARSE_INVALID_UNICODE_SURROGATE 错误。如果 \u 后不是 4 位十六进位数字,则返回LEPT_PARSE_INVALID_UNICODE_HEX 错误。

Unicode与UTF-8的区别

简言之:

  • Unicode 是字符集
  • UTF-8是编码规则

其中:

  • 字符集:为每一个字符分配一个唯一的ID(学名为码位 / 码点 / Code Point)。
  • 编码规则:码位转换为字节序列的规则(编码/解码可以理解为加密/解密的过程)

广义的Unicode是一个标准,定义了一个字符集以及一系列的编码规则,即Unicode字符集和UTF-8、UTF-16、UTF-32等等编码。

Unicode字符集为每一个字符分配一个码位,例如,“知”的码位是30693,记作U+77E530693 的十六进制为0x77E5)。

UTF-8,顾名思义,是一套以8位为一个编码单位的可变长编码。会将一个码位编码为14个字节:

U+ 0000 ~ U+ 007F: 0XXXXXXX
U+ 0080 ~ U+ 07FF: 110XXXXX 10XXXXXX
U+ 0800 ~ U+ FFFF: 1110XXXX 10XXXXXX 10XXXXXX
U+10000 ~ U+1FFFF: 11110XXX 10XXXXXX 10XXXXXX 10XXXXXX