字符编码与 Unicode 编码

字符,又或者说文本在计算机中是以字符编码的方式存储的。计算机以二进制处理信息,每一只有 10 两种状态,每个字节就可以组合出2^8=256种状态(1 字节=8 位)。以 ASCII 为例,它用 7 位的二进制来表示字母、数字和其它符号编号,共计 128 个字符,通常会额外使用一个扩充的位,以便于以 1 个字节的方式存储。

Unicode 是一个庞大的字符编码集合,是一种用于展示世界上所有语言的字符的编码标准。在 20 世纪 80 年代开始启动设计工作时,人们认为两个字节的代码宽度足以对世界上各种语言的所有字符进行编码,并有足够的空间留给未来的扩展,理论上一共最多可以表示 2^16(即 65536)个字符。这 16 位统一码字符构成了基本多文种平面,其编码范围为 U+0000 ~ U+FFFF

经过一段时间,Unicode 字符超过了 65536 个,基本多文种平面无法满足描述所有 Unicode 字符的需要了。为了扩展需求,Unicode 在原来的基础上新增了 16 个辅助平面,每个平面的编码范围为:

  • 辅助平面 1:U+10000 ~ U+1FFFF
  • 辅助平面 2:U+20000 ~ U+2FFFF
  • 辅助平面 15:U+F0000 ~ U+FFFFF
  • 辅助平面 16:U+100000 ~ U+10FFFF

所有辅助平面的编码范围为 U+10000 ~ U+10FFFF,共计 16 个辅助平面,每个辅助平面最多可存储 2^16(即 65536)个字符,也可以说每个辅助平面都有 65536 个码位

到这里为止,还只是 Unicode 的编码方式,下文简要说明其几种实现方式。

UTF-32

UTF-32 是一种 Unicode 的实现,它使用 32 位(4 字节)对每个 Unicode 码位进行编码。而 Unicode 中,即便是辅助平面 16 中的码位,也只需要 21 位就可以编码:

U+0001 => 00000000 00000000 00000000 00000001
U+10FFFF => 00000000 00010000 11111111 11111111

UTF-32 每个编码需要使用四个字节,空间浪费较多。其主要优点是可以直接由 Unicode 码位来索引。

UTF-16

UTF-16 是一种变长编码,它使用 16 位(2 字节)来表示所有的 Unicode 基本平面码位。这么一看,岂不是只能表示 U+0000 ~ U+FFFF 范围的编码?那剩余的 16 个辅助平面怎么办?

UTF-16 为了能继续表示辅助平面中的码位,使用了一种代理机制:用两个基本平面的编码组成一对来表示一个辅助平面的码位,称其为“代理对”,因此表现一个辅助平面的码位需要用到 32 位(4 字节)。

这么一来不是有问题?某个 UTF-16 编码想要表示的是基本平面的码位还是辅助平面的码位呢?为了避免冲突,在基本平面中,所有用作“代理”的编码都不定义字符,以表示将其用于“代理”。

基本平面中这些用作“代理”的编码区域被称之为“代理区”,其范围为 0xD800 ~ 0xDFFF,共 2048 个。“代理对”的前后两个代理分别称为“引导代理”、“尾随代理”。它们也有各自的取值范围:

# 引导代理(0xD800 ~ 0xDBFF)
# 1101 10pp ppxx xxxx
  1101 1000 0000 0000 ~ 1101 1011 1111 1111
     D    8    0    0      D    B    F    F

# 尾随代理(0xDC00 ~ 0xDFFF)
# 1101 11xx xxxx xxxx
  1101 1100 0000 0000 ~ 1101 1111 1111 1111
     D    C    0    0      D    F    F    F

现在简要说明一下代理规则

其中引导代理中的 110110 、尾随代理中的 110111 是定数,px 是变数。去掉定数后组合起来就是 pppp xxxx xxxx xxxx xxxx,共 20 位(2^20=1048576),刚好能够表示目前 16 个辅助平面中的全部码位(U+10000 ~ U+10FFFF,共 1048576 个)。其中 pppp 共 4 位,表示 16 个辅助平面之一的编号;紧接着的 16 位 x 表示某个辅助平面内的某个码位。

如何将某一个辅助平面的码位转换为 UTF-16 中的“代理对”编码形式呢?以 U+1F1F1 为例说明具体算法:

  1. 辅助平面中的码位值减去0x10000得到 20 位长的比特组。U+1F1F1 是辅助平面 1 的码位,代理对中的平面编号值比实际的平面编号要减去 1(平面编号索引从 0 开始)。
    # U+1F1F1
     0001 1111 00 01 1111 0001
    -0001 0000 00 00 0000 0000
    =0000 1111 00 01 1111 0001
    # 0000 代表是辅助平面 1 的码位
    
  2. 将 20 位长比特组中的高 10 位比特加上 0xD800,得到引导代理:
    # 第 1 步的高 10 位
     0000 0000 0011 1100
    +1101 1000 0000 0000 # 0xD800
    =1101 1000 0011 1100 # 0xD83C 引导代理
    
  3. 将 20 位长比特组中的低 10 位比特加上 0xDC00,得到尾随代理:
    # 第 1 步的低 10 位
     0000 0001 1111 0001
    +1101 1100 0000 0000 # 0xDC00
    =1101 1101 1111 0001 # 0xDDF1 尾随代理
    
  4. 将引导代理、尾随代理按前后顺序组合,就得到了该辅助平面码位值的 UTF-16 编码表示:
    # U+1F1F1 的 UTF-16 表示:D83CDDF1
    1101 1000 0011 1100 1101 1101 1111 0001
    

UTF-16 的编码规则较为复杂。实际上,UTF-16 在 Unicode 字符集的三大编码方式(UTF-8、UTF-16、UTF-32)中的表现也不理想。它的存在是历史原因造成的。不过由于其推出时间最早,已被应用于大量环境中。

UTF-8

UTF-8 是一种变长编码,其长度一般为 1~4 字节,当然也可以更长。

举个例子来说,所有的 ASCII 字符用一个字节就足以进行表示,那么用 UTF-32 来表示就很浪费存储空间,进行网络传输时也浪费网络资源。

相比 UTF-16 来说,UTF-8 的编码规则要容易理解的多:

  • 每个 UTF-8 编码从首字节就能判断其编码长度:
    • 首字节以0开头,为单字节编码
    • 首字节以110开头,为双字节编码
    • 首字节以1110开头,为三字节编码
    • 首字节以1111开头,为四字节编码
  • 每个多字节的 UTF-8 编码,除首字节外的其他字节均以10开头

上述规则中的010110等称之为 UTF-8 编码中的前缀码,除开前缀码之外的位就可以用来表示实际的 Unicode 码位。计算机通过前缀码就能识别 UTF-8 的每个字节的作用。

# 单字节 UTF-8,可表示 2^(8-1)=128 个字符
0xxxxxxx
# 双字节 UTF-8,可表示 2^(16-5)=2048 个字符
110xxxxx 10xxxxxx
# 三字节 UTF-8,可表示 2^(24-8)=65536 个字符
1110xxxx 10xxxxxx 10xxxxxx
# 四字节 UTF-8,可表示 2^(32-11)=2097152 个字符
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

再简单概括 UTF-8 的编码算法:

  1. 确定 UTF-8 编码中各个字节的前缀码
  2. 将 UTF-8 编码中各个字节除了前缀码所占用之外的位,依次分配给 Unicode 码位二进制中各个位的值

是不是很简单明了?UTF-8 编码设计得非常精巧,存储空间利用率较高、规则容易理解。除此之外,还有自动纠错性能好、利于传输、扩展性强等优点。而劣势在于,因为其编码长度可变,所以不利于程序的处理,例如导致正则表达式检索的复杂度大为增加。