字符集与编码(四)--Unicode

摘要: 本文系统介绍了 Unicode 方面的一些重要知识, 如码点, 平面, 代理区, 代理对以及 UTF, 用具体的例子讲解了码点到 UTF-8 及 UTF-16 的转换原理与过程. 文中还顺便鸟瞰了一下 BMP 字符集, 以此获取更加直观的印象.

目录

前面谈到不少的 Unicode, 但一直没有系统地谈及 Unicode 的方方面面, 所以本篇文章专门谈谈 Unicode, 当然了, Unicode 是一个庞大的主题, 这里也是拣些重要的方面谈谈而已, 免不了挂一漏万.

什么是 Unicode?

按 Unicode 官方的说法, Unicode 是 Unicode Standard(Unicode标准)的简写, 所以 Unicode 即是指 Unicode 标准.

按 wiki 的说法, 它是一个计算机工业标准(a computing industry standard).

下图来自 http://www.unicode.org/standard/WhatIsUnicode.html 中的截图, 在这里我把中文和英文的合在一起

什么是 Unicode

这样一个所谓的一个唯一的数字在 Unicode 中就叫做 码点.

Unicode 中的码点是什么?

字符集通常又叫 编码字符集(coded charset), 这里的 coded字符集编码(charset encoding) 中的 encoding 是不同的.

一个是 code, 一个是 encode, 翻译时都可以译成"编码", 但把 coded charset 译成 编号字符集 也许更不易引发误解.

  • 码点(Code Point) 即是这里的 code, 表示的是一种抽象的数字编号.
  • UTF-X 则是最终的 encoding.

这点如不明白, 仍请参见 字符集与编码(二)--编号 vs 编码 .

码点的表示形式与范围是?

U+[XX]XXXX 是码点的表示形式, X 代表一个十六制数字, 可以有 4-6 位, 不足 4 位前补 0 补足 4 位, 超过则按是几位就是几位.

以下是码点的一些具体示例:

  • U+0048
  • U+4F60
  • U+1D11E

注: 最后一个是5位的码点.

有人可能以为码点只有 4 位, 并常常将它与 UTF-16 的编码搞混, 这些都是对码点的误解.

它的范围目前是 U+0000 ~ U+10FFFF, 理论大小为 10FFFF+1=11000016. 后一个 1 代表是 65536, 因为是 16 进制, 所以前一个 1 是后一个 1 的 16 倍, 所以总共有1×16+1=17 个的 65536 的大小, 粗略估算为 17×6万=102万, 所以这是一个百万级别的数.

准确的值是 1114112, 一般记为 111 万左右即可.

16 进制的 110000 写成二进制是 100010000000000000000, 是一个 21 位的二进制数, 我们知道 210=K, 220=K×K=M, 即百万级别, 所以 221 理论上限是两百万左右.

100010000000000000000 大小基本上由第一个 1 决定, 所以也就一百万左右, 从这里也可印证前面的估算.

按照 Unicode 官方的说法, 码点范围就这些了, 以后也不会再扩充了.

为了更好分类管理如此庞大的码点数, 把每 65536 个码点作为一个 平面, 总共 17 个平面.

平面, BMP, SP

什么是平面?

由前面可知, 码点的全部范围可以均分成 17 个 65536 大小的部分, 这里面的每一个部分就是一个 平面(Plane). 编号从 0 开始, 第一个平面称为 Plane 0.

下图来自http://rishida.net/docs/unicode-tutorial/part2

Unicode 的 17 个平面

什么是 BMP?

第一个平面即是 BMP(Basic Multilingual Plane 基本多语言平面), 也叫 Plane 0, 它的码点范围是 [U+0000 ~ U+FFFF]. 这也是我们最常用的平面, 日常用到的字符绝大多数都落在这个平面内.

上图中第一个花花绿绿的平面就是 BMP.

UTF-16 只需要用两字节编码此平面内的字符.

很多人错误地把 UTF-16 当成定长两字节看待, 但只要处理的字符都在这一平面内, 一般也不会遇到什么问题.

什么是增补平面?

后续的 16 个平面称为 SP(Supplementary Planes). 显然, 这些码点已经是超过 U+FFFF 的了, 所以已经超过了 16 位编码空间的理论上限, 对于这些平面内的字符, UTF-16 采用了四字节编码.

注: 其中很多平面还是空的, 还没有分配任何字符, 只是先规划了这么多.

另: 有些还属于私有的, 如上图中的最后两个 Private Use Planes, 在此可自定义字符.

另: BMP 中也保留了部分区域供自定义字符使用.

鸟瞰 BMP 字符集

Unicode 的字符如此之多, 即使是最常用的 BMP, 它的码点空间也有 6 万多, 如果把这些字符都放到一张图片上, 会是什么情况呢? GNU Unifont 就制作了一张这样的图片. 见http://unifoundry.com/pub/unifont-7.0.03/unifont-7.0.03.bmp

提示: 打开它需要一点时间, 它的像素是 4000×4000 这个级别!

下图是它的一个缩略版本:

unicode BMP 全部字符缩略图

这是一个 256×256=65536 的表格, 横向纵向以 16 进制算都是从 00~FF(0~255).

CJK 统一汉字

你可能已经注意到上图中间一大片的区域, 没错, 它就是我们的汉字, 在 Unicode中, 称为 CJK 统一汉字(CJK: Chinese, Japanese, and Korean, 中日韩). 我们可以局部放大看一下:

Unicode 中日韩字符区

正则表达式 [\u4E00-\u9FA5] 来匹配中文的问题在哪?

你可能在不少地方见过这种写法, 严格来说这只是 Unicode 最主要的一段中文区域.

你只要稍加计算就可知这一段大小不过是两万多一点, \u4E00-\u9FA5(19968-40869), 中文怎么可能只有这两万多字呢?

这里的"天字第一号"字 4E00 是哪个字呢? 请看上面的图中左边第 4E 开头的行, 它就是"一"字, 我们还可以看到它上面还有不少偏门的汉字, 这就是后来增补的汉字了. 所以严格来说, 这个上限是不准确的. 那么它的下限又是否准确呢? 下面是 Word 的一个插入符号功能的一个截图:

unicode U+9FA5 之后的字符

可以看到 9FA5 后面也还有不少的汉字, 它们中间又还夹杂着一些符号, 所以想正确地表示 Unicode 中的汉字还是个不小的挑战.

应该说, Unicode 处在不断发展中, 它有一百多万的空间, 目前也只是定义了十万左右的字符, 还会不断增加, 汉字自然也有可能增加, 所以汉字的范围实际上是动态的, 变化的.

当然了, 常用的基本落在了这一范围内, 而事实上已经包含了许多的不常用汉字, 毕竟连只有 6 千多字的 GB2312 中都含有大量的不常用汉字.

在要求不那么严格的应用中, 按以上范围去判断基本也 OK, 而汉字这一概念实际上也没有准确定义, 比方说上图中一些"偏旁部首", 这些是"汉字"吗?

代理区

你可能还注意到前面的 BMP 缩略图中有一片空白, 这白花花一片亮瞎了我们的猿眼的是啥呢? 正如标题已经告诉你的, 这就是所谓的 代理区(Surrogate Area) 了.

Unicode 代理区位置

可以看到这段空白从 D8~DF. 其中前面的红色部分 D800–DBFF 属于 高代理区(High Surrogate Area), 后面的蓝色部分 DC00–DFFF 属于 低代理区(Low Surrogate Area), 各有四行, 大小均为 4×256=1024.

关于代理区的相关用途, 我们在讲到 UTF-16 编码时再说.

还可以看到在它之前是韩文的区域, 之后 E0 开始到 F8 的则是属于私有的(private), 可以在这里定义自己专用的字符.

前面说到 17 个平面的最后两个是私有平面, 这里的 U+E000 ~ U+F8FF 则是 BMP 平面中的私有区域(Private Use Area).

至此我们对 Unicode 的码点, 平面都有了一定的了解, 但我们还没有触及一个重要的方面, 那就是码点到最终编码的转换, 在 Unicode 中, 这称为 UTF.

什么是 UTF?

UTF 即是 Unicode 转换格式(Unicode (or UCS) Transformation Format).

关于 UCS: Universal Character Set(统一字符集), 也称 ISO/IEC 10646 标准, 不那么严格的情况下, 可以认为它和"Unicode字符集"这一概念是等价的. 如有兴趣的可以自行搜索了解.

码点如何转换成 UTF 的几种形式呢? 我想这是大家很关心的问题, 再发一次前面的一个图:

unicode 不同码点的 utf-8, utf-16, utf-32 编码示意

让我们先从最简单的 UTF-32 说起.

UTF-32

我们说码点最大的 0x10FFFF 也就 21 位, 而 UTF-32 采用的定长四字节则是 32 位, 所以它表示所有的码点不但毫无压力, 反而绰绰有余, 所以只要把码点的表示形式以前补 0 的形式补够 32 位即可. 这种表示的最大缺点是占用空间太大.

再来看稍复杂一点的 UTF-8.

UTF-8

UTF-8 是变长的编码方案, 可以有 1, 2, 3, 4 四种字节组合. 在前面的 定长与变长 篇章我们提到 UTF-8 采用了高位保留方式来区别不同变长, 如下:

utf-8 不同变长的编码模式

如上, 彩色的表示是保留的固定位, X 表示是有效编码位.

单字节最高位都是 0, 多字节的最高位都是 1.

多字节方面, 更具体的讲, N 字节模式, 首字节以 "N 个 1 再加 0 " 打头, 后跟 "N-1" 个以 "10" 打头的字节.

码点与字节如何对应?

哪些码点用哪种变长呢? 可以先把码点变成二进制, 看它有多少有效位(去掉前导0)就可以确定了.

  1. 一字节有效编码位有 7 位, 27=128, 码点 U+0000 ~ U+007F(0~127)使用一字节.

    一字节留给了 ASCII, 所以 UTF-8 兼容 ASCII.

  2. 二字节有效编码位只有 5+6=11 位, 最多只有 211=2048 个编码空间, 所以数量众多的汉字是无法容身于此的了. 码点 U+0080 ~ U+07FF(128~2047)使用二字节.

    注意: 这里码点从 128~2047, 因为去掉了一字节的码点, 所以不会占满 2048 个编码空间, 是有冗余的, 但你不能把适用于一字节的码点放到这里来编码. 下同.

  3. 三字节模式可看到光是保留位就达到 4+2+2=8 位, 相当于一字节, 所以只剩下两字节 16 位有效编码位, 它的容量实际也只有 65536. 码点 U+0800~U+FFFF(2048~65535)使用三字节编码.

    我们前面说到, 一些汉字字典收录的汉字达到了惊人的 10 万级别. 基本上, 常用的汉字都落在了这三字节的空间里, 这就是我们常说的汉字在 UTF-8 里用三字节表示.

    当然了, 这么说并不严谨, 如果这 10 万的汉字都被收录进来的话, 那些偏门的汉字自然只能被挤到四字节空间上去了.

  4. 四字节的可以看到它的有效位是 3+6+6+6=21 位, 前面说到最大的码点 10FFFF 也是 21 位, U+FFFF 以上的增补平面的字符都在这里来表示.

    按照 UTF-8 的模式, 它还可以扩展到 5 字节, 乃至 6 字节变长, 但 Unicode 说了码点就到 10FFFF, 不扩充了, 所以 UTF-8 最多到四字节就足够了.

码点到 UTF-8 如何转换?

那么具体是如何转换呢, 其实不难, 来看一个汉字"你"(U+4F60)的转换示意, 如下图所示:

码点到 utf-8 的转换示意

上图显示了一有效位为 15 位的码点到三字节转换的一个基本原理, 我们还可看到原来 4F60 中的一头一尾的两个 4 和 0 在转换后还存在于最终的三字节结果中.

UTF-8 三字节模式固定了 1110 的开头模式, 所以多数汉字总是以 1110 开头, 换成 16 进制形式, 1110 就是字母 E.

如果看到一串的 16 进制有如下的形式: EX XX XX EX XX XX… 每三个三个字节前面都是 E 打头, 那么它很可能就是一串汉字的 UTF-8 编码了.

其它变长字节转换道理也类似, 其中分组从低位开始, 高位如不足则补零. 这里就不再示例了.

最后来看最复杂的 UTF-16, 在此之前我们先要理解代理区与代理对等概念.

UTF-16

UTF-16 是一种变长的 2 或 4 字节编码模式. 对于 BMP 内的字符使用 2 字节编码, 其它的则使用 4 字节组成所谓的代理对来编码.

什么是代理区?

在前面的鸟瞰图中, 我们看到了一片空白的区域, 这就是所谓的 代理区(Surrogate Area) 了, 代理区是 UTF-16 为了编码增补平面中的字符而保留的, 总共有 2048 个位置, 均分为 高代理区(D800–DBFF)低代理区(DC00–DFFF) 两部分, 各 1024 大小.

这两个区一横一纵组成一个二维的表格, 共有 1024×1024=210×210=24×216=16×65536 个位置, 所以它恰好可以表示增补的 16 个平面中的所有字符.

当然了, 说恰好是不对的, 显然代理区就是冲着表示增补平面来设计的, 或者至少它们是一起考虑的.

下面的图片来自 wiki:

utf-16 代理区示意

什么是代理对?

一个高代理区(即上图中的 Lead(头), 行)加一个低代理区(即上图中的 Trail(尾), 列)的编码组成一对即是一个 代理对(Surrogate Pair), 必须是这种先高后低的顺序, 如果出现两个高, 两个低, 或者先低后高, 都是非法的.

在图中可以看到一些转换的例子, 如:

  • (D800 DC00)—> U+10000, 左上角, 第一个增补字符
  • (DBFF DFFF)—> U+10FFFF, 右下角, 最后一个增补字符

码点到 UTF-16 如何转换?

分成两部分:

  1. BMP 中直接对应, 无须做任何转换;
  2. 增补平面 SP 中, 则需要做相应的计算.

其实由上图中的表也可看出增补平面中, 码点就是从上到下, 从左到右排列过去的, 所以只需做个简单的除法, 拿到除数和余数即可确定行与列.

拿到一个码点, 先减去 1000016, 再除以 40016(=102410)就是所在行了, 余数就是所在列了, 再加上行与列所在的起始值, 就得到了代理对了.

  • Lead = (码点 - 1000016) ÷ 40016 + D800
  • Trail = (码点 - 1000016) % 40016 + DC00

下面以前面的 U+1D11E 具体示例了代理对的计算:

  • Lead = (1D11E - 1000016) ÷ 40016 + DB00 = D11E ÷ 40016 + D800 = 34 + D800 = D834
  • Trail = (1D11E - 1000016) % 40016 + DC00 = D11E % 40016 + DC00 = 11E + DC00 = DD1E

所以, 码点 U+1D11E 对应的代理对即是 D834 DD1E.

注意: 以上计算方式仅用于说明转换原理, 不代表实际采用的计算方式.

一个码点减去 1000016 后实际最多只有 20 位, 再除以40016(=210=100000000002), 这个除数实际是一个二进制整数, 相当于十进制中整十整百的数.

所以结果实际上低 10 位上的就是余数, 而高 10 位(或者不到 10 位)上的就是商, 可以通过更为快速的 移位 操作实现.

举个十进制的例子, 就好比是 "1234÷100=12...34", 你都不需要拿笔去算.

应该说, 代理区的设计是有效率上的考虑的, 如果我们要做转换, 应该考虑是否有系统 API 可供调用, 而不要自行去实现.

关于 Unicode 的基本知识, 就讲到这里.