我们都知道Unicode的大部分字符都是都是使用16位编码,即2个字节表示。

这也是为什么正则匹配中,Unicode使用“\uxxxx”进行匹配的原因

为什么说是大部分呢?因为还有一个神奇的区域,叫做Unicode代理对。它们需要使用4个字节来表示一个字符。

这里就给大家做介绍。


问题来源

Unicode的产生是为了处理不同语言之间的编码不兼容问题。

比如如果中文和日文的不同文字使用了同一个编码值进行表示,那么一篇中文的软件/操作系统中创作的文章,到了日文软件/操作系统中显示就会出现乱码。

Unicode期望规定一种通用的文字编码方式能够唯一的表示所有的文字字符。这样只要所在的软件/操作系统支持Unicode,任何文章都能在这些电脑上保持一致的字符显示。

显然,为了支持任意的文字,Unicode需要保证其编码范围要大于所有的文字字符数量。

那么,Unicode使用16位编码能够最多表示 ${2^{16}=65536}$,这对于当前常见的主要语言的字符,数学符号等已经基本够用。

但是值得注意的是,世界上还有很多小语种,古代语种,另外语言也会自行发展,例如近两年发展出来的emoji字符等等。如果他们也加入Unicode,那么16位编码空间就远远不够了。

设计方案

为了支持更多的字符,最简单的方法就是增加编码空间,比如3个字节或者4个字节表示一个字。

但是问题来了,如果统一使用4个字节编码,那就意味着同样一篇文章的内容,4字节编码会比2字节编码的体积,增大一倍。这对于存储和网络传输都是非常大的影响。

而且,由于我们的常用字符大部分只需要2个字节就能表示。所以这些额外的空间在大部分情况下,都是白白“浪费”了。

那么有没有一种方案,能够针对常用字符仅使用2个字节表示,对于小语种等其他语言的字符使用更多字节来表示呢?

“自然增长”方案

我们最容易想到的方案是“自然增长”的方案。即,先使用完低位的空间,当空间不够时,再增加高位空间。

例如最早的ASCII码只用了1个字节,到了编码空间不够时,再增加空间。

但是这种方式并不能解决刚刚提出的空间问题,因为它存在前导字符识别的问题。

举个栗子

image-20200222161927215

这里我创建了一个自定义字符,使用“\u12345678”四个字节表示。

但是实际上计算机解析时,他会把这个字符解析为“\u1234”和“\u5678”两个字符。

因为他不知道什么时候该按2个字节解析,什么时候该按4个字节解析。

image-20200222162034039

代理对方案

Unicode采用了代理对(Surrogate Pair)来解决。

他选择了 D800-DBFF编码范围作为前两个字节(utf-16高半区),DC00-DFFF作为后两个字节(utf-16低半区),组成一个四个字节表示的字符。

当软件解析到Unicode连续4个字节的前两个是utf-16高半区,后两个是utf-16低半区,他就会把它识别为一个字符。如果配对失败,或者顺序颠倒则不显示。

D800-DBFF可表示的编码范围有10位,DC00-DFFF可表示的编码范围也有10位,加起来就是20位(00000-FFFFF),这样就可以表示${2^{20}}$个字符。在可见的未来都不会出现不够使用的情况。

而且代理对区间的编码不能单独映射字符,因此不会产生识别错误。

处理字符映射

我们通过代理对解决了编码问题,但是对于人类阅读来说,“\uD800DC00”的表示方法还是太复杂。

而且和基本的两字节表示的Unicode编码放在一起看,并不连续。

因此Unicode将这20位的编码空间映射到了 10000-10FFFF,这样就能和2个字节 0000-FFFF表示的编码空间在一起连续表示了。

所以\uD800\uDC00=\u10000,这也是我们部分语言调试下对emoji字符的码值显示会出现5个HEX的原因。

Unicode平面

Unicode将 000000-10FFFF划分成了17个大小为FFFF的编码空间,其中000000-00FFFF就是我们最常用的字符,叫做0号平面,由2个字节组成。而其他的16个平面叫做辅助平面,由四个字节的代理对生成。

每个功能可以划分特定使用方式,这样就能实现编码和表意的统一,通过编码范围识别出字符所属的用途。例如第二辅助平面主要放置中日韩语言中一些罕见的字符。

代码识别

最后一个问题是编程语言识别问题,由于存在代理对,许多语言的string.length方法会将代理对中的字符(如emoji)个数识别成2个。这样会造成一些诸如光标定位,字符提取等方面的问题。

对于JavaScript,ES6中有String.fromCodePoint(),codePointAt(),for…of循环等方式处理。

具体可以参见阮一峰的博客字符串的新增方法 - ECMAScript 6入门

String.fromCharCode(0x20BB7)
// "ஷ"

let s = '𠮷a';
s.codePointAt(0) // 134071
s.codePointAt(1) // 57271

s.codePointAt(2) // 97

for (let x of 'a\uD83D\uDC0A') {
  console.log(x);
}
// 'a'
// '\uD83D\uDC0A'

对于C#来说可以使用StringInfo进行处理,详情可以参见我的博客2019-11-10-使用StringInfo正确查找字符个数 - huangtengxiao


参考文档:


本文会经常更新,请阅读原文: https://xinyuehtx.github.io/post/Unicode%E4%BB%A3%E7%90%86%E5%AF%B9.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。

知识共享许可协议 本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名黄腾霄(包含链接: https://xinyuehtx.github.io ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系