Unicode是人所共知的标准,一般意义上的Unicode做了两件事:
- 定义了一个集合,力图包括人类语言的大部分文字(学名叫Abstract Character Repertoire,ACR)
- 为其中每个文字符号赋予一个惟一编号(学名叫Coded Character Set,CCS)
概念上,这已经足够解决「某些语言的文字不能被正确表示」的问题了。但我们无法轻易地就在计算机程序里用编号表示字符,因为:
- 整数编号需要确定的长度,如果编号是2字节整数,那么它就无法表示第65537个字符
- 这个系统最好要与已有编码(尤其是ASCII)兼容
- 表示方法要考虑到空间,如果编号是4字节整数,那么一个ASCII文件换成Unicode体积就会膨胀到原来的4倍
UTF
为了落地解决方案,UTF(Unicode Transformation Format)应运而生,UTF做的事情也有个学名,叫Character Encoding Scheme(CES)。因为单元长度不同,UTF也有几个变种。
UTF-32
这是最直接的方案,每个字符都用32位(4字节)表示,由于32位无符号整数上限(4294967296)在可见的未来都足以包括所有Unicode字符,所以这个方案不需要考虑任何额外情况。实际上,截至2023年,Unicode包括的所有字符也没有超过150000个。
由于UTF-32是多字节编码,依据字节序的不同,UTF-32有little-endian和big-endian两个变种。如果你对两种字节序不了解,可以参看之前的文章。
UTF-32编码有个非常显而易见的好处:字符串(String)的长度就是字符数组(Character Array)的长度,这和ASCII时代的经验一致。为什么强调这点,因为其他的Unicode编码方式未必满足这个要求。
至于为什么没有UTF-64、UTF-128,只能说可以,但没必要,仓颉从棺材里爬出来他也造不出那么多字。
UTF-16
使用16位(2字节)表示字符单元的方案自然就叫做UTF-16,2字节可表示的整数上限是65536。因为Unicode包括的字符总数比这个多,这就遇到问题了。解决方法是什么呢?如果一个双字节单元不够用,那用一对就行了。
注意,这种情况和UTF-32的区别是:UTF-32是对所有字符都用4字节表示,而UTF-16仅对超过某个范围的字符用4字节表示,其他情况依然使用2字节。为什么说「某个范围」而不是65536?因为我们需要一点额外信息,来区分普通的UTF-16单元,和一对UTF-16单元的其中一部分,这样普通的UTF-16单元就表示不了65536那么多个字符了。
这种「用一对双字节单元表示单个字符」的情况,叫做UTF-16代理对(surrogate pair)。它有一个具体的编码方案:
- 如果一个字符的编码在
[0, 0xD7FF]
或[0xE000, 0xFFFF]
中,则以单个双字节单元表示 - 如果一个字符的编码在
[0x10000, 0x10FFFF]
中- 编码减去
0x10000
,得到一个20位整数(最大值0xFFFFF
) - 把20位分成两半,前一半加上
0xD800
作为高部分,后一半加上0xDC00
作为低部分
- 编码减去
[0xD800, 0xDFFF]
中单个元素不构成合法字符,仅作为代理对一部分存在
UTF-16的使用场景比想象的多。Apple平台的NSString内部用UTF-16表示;JavaScript的字符串也是UTF-16;很多要处理字符串的Windows API都分A和W结尾两种变体,A结尾的版本表示接受ASCII字符串,W结尾表示接受UTF-16字符串。
为什么没有UTF-24?因为3字节不方便。
UTF-8
UTF-8较前两者出现得更晚一些。因为用固定单字节表示字符的编码,已经被ASCII以及后续欧洲语言的扩展所占据,所以UTF-8必然是一个变长编码。UTF-32固定使用4字节,UTF-16在有代理对的情况下使用4字节,而它们都没有充分利用编码空间,类似地,UTF-8最多也可能占用4字节。
UTF-8具体的编码方式更麻烦一些,同样考虑一个在[0, 0x10FFFF]
之间的字符:
- 如果在
[0, 0x7F]
之间,则用1字节表示,这点保证了和ASCII的兼容 - 如果在
[0x80, 0x7FF]
之间,用2字节表示,第一个字节以110
开头,后续字节以10
开头110abcde 10fghijk
,表示abc_defg_hijk
(每4个二进制位构成1十六进制位)
- 如果在
[0x800, 0xFFFF]
之间,用3字节表示,第一个字节以1110
开头,后续字节以10
开头1110abcd 10efghij 10klmnop
表示abcd_efgh_ijkl_mnop
- 如果在
[0x10000, 0x10FFFF]
之间,用4字节表示,第一个字节以11110
开头,后续字节以10
开头11110abc 10defghi 10jklmno 10pqrstu
表示a_bcde_fghi_jklm_nopq_rstu
UTF-8最早由Ken Thompson和Rob Pike提出,二位也同样是Go语言的发明者。
UCS
在某些地方可能还会出现UCS这个名词,它是Universal Coded Character Set的缩写。Unicode相关的各种组织、标准、概念的历史都相当复杂,UCS和Unicode起初也不是从属关系。但今天,大可以粗略地将UCS-4理解为UTF-32的同义词,而UCS-2代表「没有代理对的UTF-16」(也就是只能表示65536个字符)。
BOM
每个单元由2个或以上字节组成的编码方式,必然都要考虑字节序的问题。在UTF实践中,文件开头会有称作BOM (Byte-order mark)的标记以明确告知字节序。另一篇专门谈论字节序的文章也提到了BOM,简要说就是:文件开头的0xFE 0xFF
表示big-endian,0xFF 0xFE
表示little-endian。UTF-8因为最小单元为1字节,因此不需要BOM。
不是你想的那个字符
本文到此为止,明确区分了字节与字符的概念。但仍统一将整个文字体系中的多个概念称为字符,这依然是不精确的。所以再对几个都可以被称作字符的不同概念作出区分:
- 编码单元 (code unit),是一种编码方式中的最小单元,比如UTF-8的编码单元就是1字节,UTF-16的编码单元是2字节
- 编码点 (code point),是一个或多个编码单元组成的文本单位,也就是前文中介于
[0, 0x10FFFF]
之间,需要被编码的那个东西 - 字位 (grapheme),是有意义的最小书写单位,一个字位可能由一个或多个编码点组成。字位和编码点的区别是:一个字位能对应一个可见的文本单元,而编码点则不一定
- 字形 (glyph),是在文本中能独立显示的图形符号,一个字形可以由一个或多个字位构成。此概念已经超出字符编码的讨论范围,而更多和字体渲染技术相关
了解完UTF-16和UTF-8这样的编码以后,我们很容易意识到将编码单元理解为想象中的字符并不合适。那编码点和字位又有什么区别呢?
emoji
你一定很熟悉emoji了。emoji涵盖了基本表情、旗帜、生活用品还有家庭标记,而对出现人的部分还具备不同肤色的变体。而这些emoji的具体编码并不是逐个对应到一个独立编码点那么简单。
- 在JavaScript中,😁这个字符串的长度为2,因为它的编码是
0x1F601
,在UTF-16里需要代理对表示 - 但国旗🇨🇳的长度就不是2了,而是4
- 🤵♂️的长度是5,带肤色的变体🤵🏻♂️则是7
- 表示情侣的emoji 👩❤️👨长度为8,如果带上肤色👩🏻❤️👨🏻则变成了12
- 也不是所有旗帜的长度都是4,比如彩虹旗🏳️🌈长度就是6
这到底是怎么回事!?
答案不难理解:JavaScript使用UTF-16,字符串长度代表UTF-16编码单元的数量,因此长度大于2的emoji都不是单个编码点,而是靠多个编码点组合形成的,这也就是字位和编码点的不同。来一一解释前面提到的emoji:
首先是国家和地区的旗帜,用两个特殊编码点表示,它们实际上是由两个特殊区域的字母拼出来的。在对emoji支持不佳的文本编辑器内,输入🇨🇳再按退格会留下字符🇨,被删掉的那个其实是🇳。这组字母位于[0x1F1E6, 0x1F1FF]
的范围。其中所有的旗帜都按照ISO 3166-1中的二字符格式表示,比如中国为CN (🇨 🇳),美国为US (🇺 🇸),法国为FR (🇫 🇷),可以试着复制再删掉这几对特殊字符中的空格。Windows没有为国旗emoji设计内容,所以在Windows上这些字位会被实诚地显示为两个字母。
然后是穿礼服的男性🤵♂️,长度为5,试着把它拆成编码点:0x1F935 0x200D 0x2642 0xFE0F
:
0x1F935
自身代表一个无性别的形象🤵0x200D
在Unicode里代表「零宽度连字符」(zero-width joiner, ZWJ),表示将前后字符连接在一起,在其他语言中也有用到0x2642
单独看是字符♂,表示男性,在这里和零宽度连字符一起修饰emoji的性别0xFE0F
是Unicode中的变体修饰符,[0xFE00, 0xFE0F]
中16个编码点都是变体修饰符,该修饰符表示按emoji图像显示(有些字符如0️⃣既可以表示为emoji图像也可以表示为传统字符如0️︎,它们以末尾的修饰符作区分),但因为礼服男性的emoji只有图像风格,所以删去也不影响显示
带肤色的礼服男性🤵🏻♂️长度为7,在第一个码点后多了一个码点0x1F3FB
,单独显示为🏻,在特定emoji后作为肤色修饰符。肤色修饰符位于[0x1F3FB, 0x1F3FF]
,共五种,不带肤色修饰符则以默认黄色显示。
彩虹旗🏳️🌈分解出来也不复杂,它在JavaScript里长度为7,由4个编码点构成,分别是表示白旗🏳️的0x1F3F3
,修饰符0xFE0F
,零宽度连字符0x200D
,以及彩虹🌈的0x1F308
。
多人组合恐怕是emoji中最可怕的部分,因为要考虑多个性别、肤色的组合。拆开情侣emoji 👩❤️👨得到6个编码点:
0x1f469
表示女人的形象👩- 连字符
0x200d
- 桃心❤的
0x2764
- 修饰桃心为emoji的修饰符
0xFE0F
,两者组合构成❤️ - 连字符
0x200d
0x1f468
表示男人的形象👨
带肤色的情侣emoji会比默认版本多两个编码点,分别是在左侧形象和右侧形象后面跟上肤色修饰符。因为多出来的两个编码点都需要代理对,所以总长度为12(个UTF-16编码单元),也就是24字节。
这里的人-心-人的拼接并不是任意而为,而是Unicode为这个emoji特别设计的组合。但emoji中还有其他地方用到了类似的组合,比如男性技术人员👨💻实际上就是👨加上连字符再加上💻的组合。
emoji到今天已经数量繁多,这里没有囊括它的所有规则,但以上几个例子足以说明emoji如何被「拼接」而成,以及字位和编码点在实际环境的不同。
JavaScript的Unicode字符串常量"\u1234"
仅支持小于0xFFFF
的情况,编码点更高的字符需要手动拆成代理对,这里提供一段代码以方便实验emoji编码:
function concatUnicodeScalar(values) {
return values.map(scalar => {
if ((scalar >= 0 && scalar <= 0xd7ff) || (scalar >= 0xe000 && scalar <= 0xffff))
return String.fromCharCode(scalar)
else if (scalar >= 0x10000 && scalar <= 0x10ffff)
return String.fromCharCode(((scalar - 0x10000) >> 10) + 0xd800) + String.fromCharCode(((scalar - 0x10000) & 0x3ff) + 0xdc00)
else
throw 'Bad unicode scalar'
}).join('')
}
// Run as concatUnicodeScalar([0x1F935, 0x200D, 0x2642, 0xFE0F])
连接符
但并不是只有emoji当中才体现了字位和编码点的区别。天城文、马来文等文字也使用了零宽度连接符。
对于汉字,Unicode还有一组「表意文字描述字符」,可以通过描述结构和每个部分来造字。如0x2FFA
表示左下的半包围结构,后跟「礼」与「分」即可组成「𥘶」这个汉字。从0x2FF0
到0x2FFF
,Unicode定义了16种描述字符,涵盖了不同的汉字结构。尽管编码层面支持了这套功能,但由于各种技术限制,目前的字体渲染并不支持显示出一个不存在的汉字。
连字
前面提到了,在字位之上还有个层次更高的概念叫做字形。这个概念通常和字体渲染相关。这两者的区别,就主要在连字 (ligature) 之上。
最典型的连字就是小写拉丁字母的fi和fl,i头上的点会被隐藏,然后和f连在一起。如今许多编程字体也利用了这一特性,将多字符操作符渲染为具有数学公司色彩的单个符号。
Unicode还专门定义了一个叫做零宽度非连字符 (zero-width non-joiner, ZWNJ) 的编码点,插入连字字符之中即可让它们不作为连字显示。
总结
- Unicode定义了一组可显示的符号作为字位,每个字位由一个或多个编码点构成,Unicode为每个编码点赋予一个惟一编号
- 不同的UTF使用不同方法将每个编码点对应到一组字节,其中UTF-16和UTF-8是变长编码,UTF-16的变长情况称作代理对
- emoji有套复杂的规则将若干编码点组合成一个emoji字位,这套规则使emoji有了创造不同肤色、性别变体的能力
- 在实际的字体渲染中,因为连字的存在,多个字位也可能被当作一个整体进行渲染
发表回复