这是 Cornerstone 基石系列的第二篇,主题是 Unicode 与 UTF。
¶开始前的一些准备
首先我们需要在脑海中记熟一些二进制的转换关系,以便直观地理解 Unicode。
储存大小 | 范围 | 十进制大小 |
---|---|---|
4 bit | 0x0 - 0xF | 24 = 16 |
1 Byte | 0x00 - 0xFF | 28 = 256 |
2 Bytes | 0x0000 - 0xFFFF | 216 = 65,536 |
其次,我们需要了解字节序。字节序分为大端序(Big-Endian, BE)与小端序(Little-Endian, LE)。只需要记住,大端序的最高位字节存储在最低的内存地址处,而小端序的最高字节存储在最高的内存地址处。
以下是存储 32 位整数 0x12345678 的例子:
内存地址 | 大端序 | 小端序 |
---|---|---|
0x03 | 0x12 | 0x78 |
0x02 | 0x34 | 0x56 |
0x01 | 0x56 | 0x34 |
0x00 | 0x78 | 0x12 |
¶什么是 Unicode
Unicode(中文:万国码、国际码、统一码、单一码)是电脑科学领域里的一项业界标准。它对世界上大部分的文字系统进行了整理、编码,使得电脑可以用更为简单的方式来呈现和处理文字。[1]
需要注意的是,Unicode 准确来说是一种字符集,而与我们日常中使用的各种编码有所区别。Unicode 给每一个它所管辖的字符分配了一个唯一而确定的 ID(二进制整数),但并没有规定这些字符应该如何储存、传输。规定这些字符如何的则是 UTF(Unicode Transformation Format)标准,即 UTF 是对 Unicode 的编码。
¶Unicode 的组成
¶代码点(Code Point / Code Position)
代码点用来称呼分配给字符的整数。例如,在 Unicode 中字符“爱”的代码点为 U+7231[2]。在表示代码点时,习惯用 U+ 作为前缀,然后紧接该代码点的十六进制数字。
¶代码空间(Code Space)
所有代码点组成集合称为代码空间。Unicode 的代码空间范围为 U+0000 到 U+10FFFF,一共 1,114,112 个代码点。截至本文写作时间,Unicode 最新标准 12.1.0[3] 已经给 137,929 个字符分配了代码点。
P.S. Unicode 12.0.0 至 12.1.0 只新增了一个字符,即日本新年号令和的合字(U+32FF)[3]。
¶平面(Plane)
Unicode 将 U+0000 到 U+10FFFF 分割为 17 个平面,每个平面拥有 65,536 个码点(即 2 字节)。其中,0 号平面称为基本多文种平面(Basic Multilingual Plane, BMP),其他平面则成为补充平面 / 辅助平面(Supplementary Plane)。不同平面的具体划分和作用如下[4]:
平面 | 始末字符值 | 中文名称 | 英文名称 |
---|---|---|---|
0 号平面 | U+0000 - U+FFFF | 基本多文种平面 | Basic Multilingual Plane, BMP |
1 号平面 | U+10000 - U+1FFFF | 多文种补充平面 | Supplementary Multilingual Plane, SMP |
2 号平面 | U+20000 - U+2FFFF | 表意文字补充平面 | Supplementary Ideographic Plane, SIP |
3 号平面 | U+30000 - U+3FFFF | 表意文字第三平面(未正式使用) | Tertiary Ideographic Plane, TIP |
4~13 号平面 | U+40000 - U+DFFFF | (尚未使用) | |
14 号平面 | U+E0000 - U+EFFFF | 特别用途补充平面 | Supplementary Special-purpose Plane, SSP |
15 号平面 | U+F0000 - U+FFFFF | 保留作为私人使用区(A区) | Private Use Area-A, PUA-A |
16 号平面 | U+100000 - U+10FFFF | 保留作为私人使用区(B区) | Private Use Area-B, PUA-B |
BMP 是 Unicode 中最重要的平面,常用的字符都定义在这个平面中。
在BMP中定义的代码点包括[5]:
- ASCII 共 128 个字符,占据了 BMP 的前 128 个代码点
- ISO-8859-1 共 256 个字符,占据了 BMP 的前 256 个代码点
- CJK Unified Ideographs 占据 BMP 大约 1/3,定义了两万多个汉字,其中前 20,902 个汉字是按照《康熙字典》里笔画顺序排列的
- Surrogate Code Points 从 U+D800 到 U+DBFF 的 1024 个代码点是 High-surrogate 代码点,从 U+DC00 到 U+DFFF 的 1024 个代码点是 Low-surrogate 代码点。这 2048 个代码点并不是有效的字符代码点,它们是为 UTF 编码保留的。一个 High-surrogate 代码点和一个 Low-surrogate 代码点组成一个代理对(Surrogate Pair),可以在 UTF-16 里编码 BMP 之外的某个代码点。
以上便是 Unicode 的一些介绍。
详细的标准定义请参考 https://unicode.org。
以及推荐一个用来查询 Unicode 字符的网站 https://unicode-table.com/cn/,无聊的时候翻翻还是特别好玩der。
¶UTF 编码
以下将介绍三种 UTF 格式,即 UTF-8、UTF-16 和 UTF-32。
¶UTF-32
最直观的一个实现方法就是将 Unicode 代码点直接存储。UTF-32 就是这样的一种实现,它将每个字符都存储为 4 字节的代码点,这样的好处是不需要进行解析就可以直接将 UTF-32 存储的字符进行翻译,然而因为常用的字符几乎都存在于 BMP 中,所以这样的实现方案是对空间的极大浪费。目前,UTF-32 几乎没有任何实际用途,甚至于 HTML5 标准明文规定网页不得使用 UTF-32 存储[6]。
¶UTF-8
UTF-8 是一种可变长的字符编码,其设计兼顾了存储效率和兼容性,是目前使用最为广泛的一种 UTF 编码。UTF-8 使用 1 到 4 字节来编码 Unicode(UTF-8 编码的最高上限是 6 字节,而事实上 4 字节就已经足够完成 Unicode 代码空间的编码)。其中,1 字节的 UTF-8 编码完全与 ASCII 编码兼容。
UTF-8 的编码规则很简单:
- 对于单字节符号,该字节的第一位设为 0,后面 7 位为该符号的 Unicode 代码点。
- 对于 n(n > 1) 字节的符号,第一个字节的前 n 位设为 1, 第 n + 1 位设为 0,之后的字节前两位一律设为 10,其余没有提及的二进制位为该符号的 Unicode 代码点。
Unicode 范围 | UTF-8 |
---|---|
U+0000 - U+007F | 0xxxxxxx |
U+0080 - U+07FF | 110xxxxx 10xxxxxx |
U+0800 - U+FFFF | 1110xxxx 10xxxxxx 10xxxxxx |
U+010000 - U+10FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
还是以字符“爱”作为例子,U+7231 在 U+0800 - U+FFFF 范围内,所以采用 3 个字节进行编码。U+7231 转换成二进制为 01110010 00110001,填入 1110xxxx 10xxxxxx 10xxxxxx 可以得到 11100111 10001000 10110001,转换成十六进制为 E7 88 B1,即“爱"的 UTF-8 编码为 E7 88 B1。
有一个问题是,之前提到过字节序,那么 UTF-8 的存储是否受到字节序影响呢?答案是否定的。UTF-8 的存储其实是以单字节为单元的,应用程序在解析和存储 UTF-8 编码时,应该一个字节一个字节地进行处理,所以 UTF-8 的存储与字节序无关。然而,上面的 UTF-32 和接下来讲到的 UTF-16,其存储单元分别为四个字节和两个字节,因此和字节序有关。
¶UTF-16
UTF-16 同样是一种可变长的字符编码,其使用 2 到 4 字节来编码 Unicode。
还记得上文中提到的范围为 U+D800 至 U+DFFF 的代理代码点吗?这些代码点便是用来实现 UTF-16 编码的。
UTF-16 的编码规则如下:
对于在 U+0000 至 U+D7FF 和 U+E000 至 U+FFFF 范围内的代码点(即 BMP 内所有除了代理代码点之外的代码点),UTF-16 将其代码点直接存储为 2 字节的内容。
对于在 U+10000 至 U+10FFFF 范围内的代码点(即所有辅助平面的代码点),UTF-16 将其编码为一对 2 字节共 4 字节的内容,其内容的生成方法如下:
代码点减去 0x10000,得到的值范围处于 0x00000 至 0xFFFFF内,共 20 比特。
该值的前 10 比特被加上 0xD800,得到第一个 2 字节的内容,称之为前导代理(Lead Surrogates)。
该值的后 10 比特被加上 0xDC00,得到第二个 2 字节的内容,称之为后尾代理(Trail Surrogates)。
以上的叙述过于繁杂,我们还是以字符“爱”为例,对其进行 UTF-16 编码。
代码点 U+7321 处于 BMP 中,且不是代理代码点,所以其 UTF-16 的编码直接为 73 21。
我们再以代码点 U+1F600(字符为 emoji 笑脸 “😀”)为例。先将其减去 0x10000,得到 0xF600,二进制为 0000111101 1000000000,前 10 比特转换成十六进制为 0x003D,加上 0xD800 等于 0xD83D,后 10 比特转换成十六进制为 0x0200,加上 0xDC00 等于 0xDE00,所以其 UTF-16 的编码为 D8 3D DE 00。作为比较,其 UTF-8 的编码为 F0 9F 98 80(编码过程请读者自行推导)。
需要注意的是,以上两个 UTF-16 编码都是小端序编码,即 UTF-16LE。作为比较,其大端序编码(UTF-16BE)分别为 21 73 和 3D D8 00 DE。请读者特别注意 UTF-16 的编码单元为 2 字节,所以其 4 字节编码在大端序和小端序的区别只是编码单元内颠倒,前导代理和后尾代理的顺序并没有改变。
思考题:UTF-16 的设计如何识别一个字符编码到底是 2 字节还是 4 字节?
值得一提的是,将 UTF-8 与 UTF-16 进行对比可以发现,对于常用的汉字(CJK 字符集),UTF-16 将其编码为 2 个字节,而 UTF-8 则大多将其编码为 3 个字节。因此,对于汉字(CJK 字符集)集中的文本,UTF-16 编码后的大小要显著小于 UTF-8。
¶Byte Order Mark (BOM)
UTF-16 和 UTF-32 有字节序的问题,那么如何分辨一段 UTF-16 或 UTF-32 编码的文本究竟是大端序还是小端序呢?这就需要在文本之前附加一段 BOM。为了能够兼容不支持解析 BOM 的编辑器,Unicode 标准采用了零宽度无端空白字符(U+FEFF,目前已弃用)作为 BOM[7](然而事实上 BOM 还是造成了很大的兼容性问题)。因为 UTF-8 并没有字节序的问题,其也不需要 BOM,然而 Unicode 也还是为其设计了一个 BOM,用来作为可选的 UTF-8 编码标识。
编码 | BOM |
---|---|
UTF-8 | EF BB BF |
UTF-16LE | FE FF |
UTF-16BE | FF FE |
UTF-32LE | 00 00 FE FF |
UTF-32BE | FF FE 00 00 |
以上便是对 Unicode 和 UTF 的简单介绍。更多细节性的东西请参考其标准定义(其实读读这些定义文档还是十分有趣的)。
¶Unicode 与 JacaScript
Unicode 在 JavaScript 存储时的字节序问题略过不表,将在以后有关 Buffer 的文章中说明。
由于历史原因,在 ES6 之前 JavaScript 只支持 UCS-2 编码(即 UTF-16 的 BMP 部分),因此对于 4 字节的 UTF-16 字符,JavaScript 会将其识别为两个字符。所幸,ES6 针对这个问题进行了优化,目前 ES6 已经较为完善地支持 UTF-16 了(然而由于兼容性问题,4 字节的 UTF-16 字符在 JavaScript 中作为字符串的长度仍然为 2)[8]。
有关 ES6 针对 Unicode 的支持网上各方面的资料已经叙述的十分详细了,而且这方面的内容在日常码代码时很少用到,这里也就略过,有兴趣的读者可以自行搜索。这里只说一点,ES6 在字符串中提供了 \u{}
转义,例子如下:
1 | '😀' === '\u{1F600}' |
¶写在最后
这篇文章写得比较仓促,所以相关的知识也比较简略,大概算是一个用于科普的随手记,主要是写给自己看的,以防自己忘记这些基础的东西。
同时这篇文章与 JavaScript 和 Node.js 的关系不大。其实本来计划的第二篇是直接写有关 Buffer 等 JavaScript 和 Node.js 中的二进制数据处理知识,然而在学习相关知识的过程中发现自己对 Unicode 毫无了解,就打算临时改变主题,了解了一下 Unicode。其实 Unicode 涉及到的知识和历史十分庞杂,这篇文章所写的也只是基础中的基础,毕竟字符集和字符编码是从计算机出现伊始至今都是一个十分重要的问题,如果要详细叙述,写成几本专著都不为过。不过本文的知识用于应付日常码代码也应该还是足够,对于编程和运维中的编码问题也能够提供一些解决思路。
以及,之后的基石系列考虑到时间和精力问题,可能在叙述方面会变得越来越精简,毕竟这个系列的初衷还是为了写给自己看,督促自己夯实基础、努力学习的。当然如果有什么问题和建议,欢迎与我联系(评论万年不看,建议邮箱 / QQ)。
下一篇的预计完成时间应该在国庆期间,当然能提早会尽量提早。
有关其他的项目和坑,仍然在整理当中,欢迎继续关注.jpg 我们下一篇见!