Unicode的三三两两
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
:
U+20AC
在U+0800~U+FFFF
的范围内,应编码成3个字节。U+20AC
的二进位为10 0000 1010 1100
- 3个字节的情况需要要16位的码点,所以在前面补两个0,成为
0010 0000 1010 1100
- 按上表把二进制分成3组:
0010
,000010
,101100
- 加上每个字节的前缀:
11100010
,10000010
,10101100
- 用十六进位表示即:
0xE2
,0x82
,0xAC
对于上述例子,其对应的代码为:
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
,我们需要:
- 将4位十六进制整数解析为码点
- 由于字符串是以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+77E5
(30693
的十六进制为0x77E5
)。
UTF-8,顾名思义,是一套以8
位为一个编码单位的可变长编码。会将一个码位编码为1
到4
个字节:
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