Unicode字符集和编码

编码的历史

计算机没法直接处理文本,它只和数字打交道。为了在计算机里用数字表示文本,我们指定了一个从字符到数字的映射。这个映射就叫做[编码(encoding)]

最有名的一个字符编码是 ASCII。ASCII 码是 7 位的,它将英文字母,数字 0-9 以及一些标点符号和控制字符映射为 0-127 这些整型。随后,人们创造了许多不同的 8 位编码来处理英语以外的其他语言。它们大多都是基于 ASCII 编码的,并且使用了 ASCII 没有使用的第 8 位来编入其它字母、符号甚至是整个字母表(比如西里尔字母和希腊字母)。
随着计算机的发展,各个国家都开始需要自己的字符集,所以就出现可各种各样的编码,这些编码系统相互之间并不兼容
(http://en.wikipedia.org/wiki/Character_encoding)。
当然,这些编码系统相互之间并不兼容,并且,由于 8 位的空间对于欧洲的文字来说都不够,更不用说全世界的书写系统了,因此这种不兼容是肯定会出现的了。这对于当时基于文本的操作系统来说是很麻烦的,因为那时操作系统只能同时使用一种编码(也叫做内码表,code page)。如果你在一台机器上写了一段文字,然后在另一台使用了不同的内码表的机器上打开,那么在 128-255 这个范围内的字符就会显示错误。

诸如中文、日文和韩文的东亚文字又让情况更加复杂。这些书写系统里包含的字符实在是太多了,以至于 8 位的数字所能提供的 256 个位置远远不够。结果呢,人们开发了更加通用的编码(通常是 16 位的)。当你开始纠结于如何处理一个字节装不下的值时,如何把它存储到内存或者硬盘里就变得十分关键了。这时,就必须再进行第二次映射,以此来确定字节的顺序。而且,最好使用可变长度的编码而不是固定长度的。请注意,第二次映射其实是另一种形式的“编码”。我们把这两个映射都叫做“编码”很容易造成误解。这个在下面 UTF-8 和 UTF-16 的部分里会再作讨论。

现代操作系统都已经不再局限于只能同时使用一种内码表了,因此只要每个文档都清楚地标明自己使用的是哪种编码,处理几十甚至上百种编码系统尽管很讨厌,也完全是有可能的。真正不可能的是在一个文档里混合使用多种编码系统,因此撰写多语言的文档也不可能了,而正是这一点终结了在 Unicode 编码出现之前,多种编码混战的局面。

Unicode字符集

基本介绍

简单地来说,Unicode 标准为世界上几乎所有的[^1]书写系统里所使用的每一个字符或符号定义了一个唯一的数字。这个数字叫做码点(code points),以 U+xxxx 这样的格式写成,格式里的 xxxx 代表四到六个十六进制的数。比如,U+0041(十进制是 65)这个码点代表拉丁字母表(和 ASCII 一致)里的字母 A;U+1F61B 代表名为“伸出舌头的脸”的 emoji,也就是 (顺便说一下,字符的名字也是 Unicode 标准的一部分。)。
就像上文提到的其它编码一样,Unicode 以抽象的方式代表一个字符,而不规定这个字符应该如何呈现(render)。如此一来,Unicode 对中文、日文和韩文(CJK)里使用的汉字(也就是所谓的统一汉字)都使用完全相同的码点(这一决定颇具争议),尽管在这些书写系统里,每个汉字都发展出了独特的字形(glyph)变体。

最初,Unicode 编码是被设计为 16 位的,提供了 65,536 个字符的空间。当时人们认为这已经大到足够编码世界上现代文本里所有的文字和字符了。不再使用的和罕见的字符本应在需要时被编入 Private Use Areas,这是 65,536 个字符的空间里一个指定的区域,各个机构可以用它来定义自己的字符映射(这有可能导致冲突)。苹果在这个区域里编入了一些自定义符号和控制字符(文档在这里),虽然大多数已经不再使用了,但是苹果的 logo 是个著名的例外:,它的码点是 U+F8FF。(你可能看到的是另一个不同的字符,这取决于你阅读本文的平台)。

后来,考虑到要编码历史上的文字以及一些很少使用的日本汉字和中国汉字[^2],Unicode 编码扩展到了 21 位(从 U+0000 到 U+10FFFF)。Unicode 不是 16 位的编码!它是 21 位的。这 21 位提供了 1,114,112 个码点,其中,只有大概 10% 正在使用,所以还有相当大的扩充空间。

编码空间被分成 17 个平面(plane),每个平面有 65,536 (2^16))个字符。0 号平面叫做「基本多文种平面」(Basic Multilingual Plane, BMP),涵盖了几乎所有你能遇到的字符,除了 emoji。其它平面叫做补充平面,大多是空的。

为什么Unicode的最大值为\x10FFFF?因为对于UTF16编码,双字节最多可编码2^20 个字符,单字节可编码2^16个字符,加起来共17个平面的字符数。

Unicode的特性

最好将 Unicode 看做是已有的各编码系统(它们大多是 8 位的)的统一,而不是一个通用的编码。考虑到要兼容一些古老的编码系统,这个标准包含了一些需要注意的地方,你需要了解它们才能在你的代码里正确地处理 Unicode 字符串。

组合字符序列

为了和已有的标准兼容,某些字符可以表示成以下两种形式之一:一个单一的码点,或者两个以上连续的码点组成的序列。例如,有重音符号的字母 é 可以直接表示成 U+00E9(「有尖音符号的小写拉丁字母 e」),或者也可以表示成由 U+0065(「小写拉丁字母 e」)再加 U+0301(「尖音符号」)组成的分解形式。这两个形式都是组合字符序列的变体。组合字符序列不仅仅出现在西方文字里;例如,在谚文**(朝鲜、韩国的文字)中,가 这个字可以表示成一个码点(U+AC00),或者是 ᄀ + ᅡ (U+1100,U+1161)这个序列。

在 Unicode 的语境下,两种形式并不相等(因为两种形式包含不同的码点),但是符合「标准等价」(canonically equivalent),也就是说,它们有着相同的外观和意义。
标准等价:有相同的外观和意义,但是组成的Unicode码不同

重复字符

许多看上去一样的字符都在不同的码点编码了多次,以此来代表不同的含义。例如,拉丁字母 A(U+0041)就与西里尔字母 A(U+0410)完全同形,但事实上,它们是不同的。把它们编入不同的码点不仅简化了与老的编码系统的转换,而且能让 Unicode 的文本保留字符的含义。

但也有极少数真正的重复,这些完全相同的字符在不同的码点上定义了多次。例如,Unicode 联盟就列举出了字母 Å(「上面带个圆圈的大写拉丁字母 A」,U+00C5)和字符 Å(「埃米」(长度单位)符号,U+212B)。考虑到「埃米」符号其实就是被定义成这个瑞典大写字母的,因此这两个字符是完全相同的。在 Unicode 里,它们也符合标准等价但不相等。

还有更多的字符和序列是更广意义上的「重复」,在 Unicode 标准里叫做「相容等价」(compatibility equivalence)。相容的序列代表着相同的字符,但有着不同的外观和表现。例子包括很多被用作数学和技术符号的希腊字母,还有,尽管已经有了从 U+2160 到 U+2183 这个范围里的标准拉丁字母,罗马数字也被单独编入 Unicode。其它关于相容等价的典型例子就是连字(ligature):字母 ff(小写拉丁连字 ff,U+FB00)和 ff 的序列(小写拉丁字母 f + 小写拉丁字母 f,U+0066 U+0066)就符合相容等价但不符合标准等价,虽然它们也可能以完全一致的样子呈现出来,这取决于环境、字体以及文本的渲染系统。
相容等价:相容的序列代表着相同的字符,但有着不同的外观和表现

正规形式

从上面可以看出,在 Unicode 里,字符串的等价性并不是一个简单的概念。除了一个码点一个码点地比较两个字符串以外,我们还需要另一种方式来鉴定标准等价和相容等价。为此,Unicode 定义了几个正规化(normalization)算法。正规化一个字符串的意思是:为了能使它与另一个正规化了的字符串进行二进制比较(binary-compare),将其转化成有且仅有的唯一一个表示形式,这个形式由等价字符的序列组成。

Unicode 标准里包含了四个正规形式,分别是 C、D、KD 和 KC。它们可以放入一个 2*2 的矩阵里(下面还列举出了 NSString 提供的对应方法):

截屏2021-10-17 18.26.13.png

仅仅为了比较的话,先把字符串正规化成分解形式(D)还是合成形式(C)并不重要。但 C 形式的算法包含两个步骤:先分解字符再重新组合起来,因此 D 形式要更快一些。如果一个字符序列里含有多个组合标记,那么组合标记的顺序在分解后会是唯一的。另一方面,Unicode 联盟推荐 C 形式用于存储,因为它能和从旧的编码系统转换过来的字符串更好地兼容。

两种等价对于字符串比较来说都很有用,尤其在排序和查找时。但是,要记住,如果要永久保存一个字符串,一般情况下不应该用相容等价的方式去将它正规化,因为这样会改变文本的含义:

不要对任意文本都盲目地使用 KC 或 KD 这两种正规形式,这样会清除很多格式上的差异。它们能防止与许多老旧的字符集之间的循环转化,与此同时,除非有格式标记代替,否则 KC 和 KD 还会清除许多对文本的语义很重要的差异。最好把这些正规形式想成字母大写与小写之间的映射:有时在需要辨认很重要的意思时很有用,但也会不恰当地修改文本。

字体变形

有些字体会为一个字符提供多个字形(glyph)变体。Unicode 提供了一个叫做「变体序列」(variation sequences)的机制,它允许用户选择其中一个变体。这和组合字符序列的工作机制完全一样:一个基准字符加上 256 个变体选择符(VS1-VS256,U+FE00 到 U+FE0F,还有 U+E0100 到 U+E01EF)中的一个。Unicode 标准对「标准化变体序列」(Standardized Variation Sequences,在 Unicode 标准中定义)和「象形文字变体序列」(Ideographic Variation Sequences,是由第三方提交给 Unicode 联盟的,一旦注册,它可以被任何人使用)做出了区分。技术上来讲,两者并无区别。

emoji 的样式就是一个标准化变体序列的例子。许多 emoji 和一些「正常」的字符都有两种风格:一种是彩色的「emoji 风格」,另一种是黑白的,更像是符号的「文本风格」。例如,「有雨滴的伞」这个字符(U+2614)可能是这样:☔️ (U+2614 U+FE0F) ,也可能是这样的: ☔︎ (U+2614 U+FE0E)。

Unicode的编码

从上文可以看到,字符和码点之间的映射只完成了一半工作,还需要定义另一种编码来确定码点在内存和硬盘中要如何表示。Unicode 标准为此定义了几种映射,叫做「Unicode 转换格式」(Unicode Transformation Formats,简称 UTF)。日常工作中,人们就直接把它们叫做「编码」—— 因为按照定义,如果是用 UTF 编码的,那么就要使用 Unicode,所以也就没必要明确区分这两个步骤了。

UTF-32编码

最清楚明了的一个 UTF 就是 UTF-32:它在每个码点上使用整 32 位。32 大于 21,因此每一个 UTF-32 值都可以直接表示对应的码点。尽管简单,UTF-32却几乎从来不在实际中使用,因为每个字符占用 4 字节太浪费空间了。

UTF-16编码

UTF-16 要常见得多,而且在下文我们会看到,它与我们讨论 NSString 对 Unicode 的实现息息相关。它是根据有 16 位固定长度的码元(code units)定义的。UTF-16 本身是一种长度可变的编码。基本多文种平面(BMP)中的每一个码点都直接与一个码元相映射。鉴于 BMP 几乎囊括了所有常见字符,UTF-16 一般只需要 UTF-32 一半的空间。其它平面里很少使用的码点都是用两个 16 位的码元来编码的,这两个合起来表示一个码点的码元就叫做代理对(surrogate pair)

为了避免用 UTF-16 编码的字符串里的字节序列产生歧义,以及能使检测代理对更容易,Unicode 标准限制了 U+D800 到 U+DFFF 范围内的码点用于 UTF-16,这个范围内的码点值不能分配给任何字符。当程序在一个 UTF-16 编码的字符串里发现在这个范围内的序列时,就能立刻知道这是某个代理对的一部分。实际的编码算法很简单,维基百科上 UTF-16 的文章里有更多介绍。UTF-16 的这种设计也是为什么码点最长也只有奇怪的 21 位的原因。UTF-16 下,U+10FFFF 是能编码的最高值。

和所有多字节长度的编码系统一样,UTF-16(以及 UTF-32)还得解决字节顺序的问题。在内存里存储字符串时,大多数实现方式自然都采用自己运行平台的 CPU 的字节序(endianness);而在硬盘里存储或者通过网络传输字符串时,UTF-16 允许在字符串的开头插入一个「字节顺序标记」(Byte Order Mask,简称 BOM)。字节顺序标记是一个值为 U+FEFF 的码元,通过检查文件的头两个字节,解码器就可以识别出其字节顺序。字节顺序标记不是必须的,Unicode 标准把高字节顺序(big-endian byte order)定为默认情况。UTF-16 需要指明字节顺序,这也是为什么 UTF-16 在文件格式和网络传输方面不受欢迎的一个原因,不过微软和苹果都在自己的操作系统内部使用它。

UTF-8编码

由于 Unicode 的前 256 个码点(U+0000 到 U+00FF)和常见的 ISO-8859-1(Latin 1) 编码完全一致,UTF-16 还是在常用的英文和西欧文本上浪费了大量的空间:每个 16 位的码点的高 8 位的值都会是 0[^3]。也许更重要的是,UTF-16 对一些老旧的代码造成了挑战,这些代码常常会假定文本是用 ASCII 编码的。Ken Thompson(他在 Unix 社区很有名) 和 Rob Pike 开发了 UTF-8 来弥补这些不足[^4]。它的设计很出色,请务必阅读 Rob Pike 对 UTF-8 创造过程的讲述。

UTF-8 使用一到四个[^5]字节来编码一个码点。从 0 到 127 的这些码点直接映射成 1 个字节(对于只包含这个范围字符的文本来说,这一点使得 UTF-8 和 ASCII 完全相同)。接下来的 1,920 个码点映射成 2 个字节,在 BMP 里所有剩下的码点需要 3 个字节。Unicode 的其他平面里的码点则需要 4 个字节。UTF-8 是基于 8 位的码元的,因此它并不需要关心字节顺序(不过仍有一些程序会在 UTF-8 文件里加上多余的 BOM)。

有效率的空间使用(仅就西方语言来讲),以及不需要操心字节顺序问题使得 UTF-8 成为存储和交流 Unicode 文本方面的最佳编码。它也已经是文件格式、网络协议以及 Web API 领域里事实上的标准了。

结语

文本很复杂。尽管 Unicode 已经极大地改善了软件处理文本的方式,程序员还是需要了解其中的运作机制以便能正确处理它。今天,几乎每一个应用都要处理多文种文本。即使你的应用不需要为中文或阿拉伯文做本地化,只要有任何一处需要用户进行输入,你还是得和 Unicode 的整个机制打交道。

你可以用全世界的语言来作完整的字符串处理测试,这意味着你需要测试输入为非英语的情况。确保在你的单元测试里用大量的 emoji 和 非拉丁文字作为测试用例。如果你不知道怎么输入某种文字,维基百科可以帮助你。这里有各种语言版本的维基百科,选择某种语言,随机选取一篇文章,拷贝里面的一些字词,然后尽情地测试吧。

扩展阅读

  • Joel Spolsky: 关于 Unicode 和字符集,每个程序员绝对、必须要了解的一点内容。这篇文章已经有 10 年了,而且不仅限于 Cocoa 编程,但是值得一读。
  • Ross Carter 在 2012 年 NSCoference 上做了一次名叫「你也可以讲 Unicode」 的精彩演讲。演讲很有意思,强烈推荐观看。这篇文章的一部分就是基于 Ross 的演讲稿的。NSConference 的 Scotty 人很好,让 objc.io 的读者可以观看这次视频。谢了!
  • 维基百科上关于 Unicode 的文章很棒。
  • unicode.org 是 Unicode 联盟的官网,上面不仅有完整的标准和码表索引,还有其它很有意思的信息。扩展部分 FAQ 也很棒。

1: 最新的 6.3.0 版本的 Unicode 标准支持 100 种文字和 15 种符号集,比如数学符号和麻将牌。在还没有提供支持的文字中,有 12 种「仍有人使用的文字」以及 31 种「古老的」或者「已经消亡的」文字。

2: 如今,Unicode 编码了超过 70,000 个统一的中日韩文字(CJK),单单这些文字就已经远远超过了 16 位所提供的空间。

3: 就连用其它文字写成的文档里也会包含大量这个范围里的字符。假设有一个 HTML 文档,它的内容全部是中文,但这个文档的字符里仍将有极大的比例是由 HTML 标记、CSS 样式、Javascript 代码、空格、换行符等组成的。

4: 我(原文作者,下同)在 2012 年的一篇博文里质疑了让 UTF-8 兼容 ASCII 的决定是否正确。事实上,我现在知道了,UTF-8 的核心目标之一就是这个兼容性,以明确避免与不支持 Unicode 的文件系统之间的问题。不过我还是觉得太多的向前兼容最后往往会成为累赘,因为即使在今天,这个特性仍然会把一些漏洞掩盖在没有充分测试的处理 Unicode 的代码里。

5: UTF-8 最初是设计用来编码最长达 31 位的码点的,而这需要最多达 6 字节的序列。后来为了遵守 UTF-16 的约束,将它限制到了 21 位。现在,最长的 UTF-8 字节序列是 4 字节的。

部分字符集

1.ASCII

这是美国在19世纪60年代的时候为了建立英文字符和二进制的关系时制定的编码规范,它能表示128个字符,其中包括英文字符、阿拉伯数字、西文字符以及32个控制字符。它用一个字节来表示具体的字符,但它只用后7位来表示字符(2^7=128),最前面的一位统一规定为0。

2.扩展ACSII码

用于表示更多的欧洲文字,用8个位存储数据,一共可以表示256个字符,它是把ACSII的最后一位也拿来编码了,应该是兼容ASCII的

3.国标编码GBK/GB2312/GB18030

GBK和GB2312都是针对简体字的编码,只是GB2312只支持六千多个汉字的编码,而GBK支持1万多个汉字编码。而GB18030是用于繁体字的编码。汉字存储时都使用两个字节来储存。

原文 NSString与Unicode
链接UTF-8编码方式
链接UTF-16编码方式
链接iOS开发NSString与Unicode

你可能感兴趣的:(Unicode字符集和编码)