(九)Unicode字符集的字符编码方式
一、字符编码方式CEF的选择
由于Unicode字符集非常大,有些字符的编号(码点值)需要两个或两个以上字节来表示,而要对这样的编号进行编码,也必须使用两个或两个以上字节。
比如,汉字“严”的Unicode码(Unicode码点值、Unicode编号)是十六进制数4E25,转换成二进制数有15位(100 1110 0010 0101),对“严”这个字符的编号进行编码的话,至少需要2个字节。表示其他更大编号的字符,可能需要3个字节或者4个字节,甚至更多。
这带来两个问题:
一是,如何才能区别Unicode字符和ASCII字符的编码?计算机怎么知道三个字节表示的是一个字符,而不是分别表示三个字符呢?
二是,我们知道,英文字母只用一个字节来编码就够了,而如果Unicode统一硬性规定,每个字符都用两个、三个或四个字节来编码,那么每个英文字母编码的前面都必然有一个、两个到三个字节全是0,这对于存储和传输来说是极大的浪费。
这就涉及到了字符编码方式CEF的选择问题。Unicode字符的编码方式一般有三种:UFF-8、UTF-16、UTF-32。在具体介绍这些编码方式之前,需要再次深入了解两个概念——码点(Code Point)与码元(Code Unit)。
二、码点
一个字符集一般可以用一张或多张由多个行和多个列所构成的二维表来表示。
二维表中行与列相交的点,称之为码点(Code Point代码点),也称之为码位(Code position代码位);每个码点分配一个唯一的编号,称之为码点值或码点编号,除开某些特殊区域(比如代理区、专用区)的非字符码点和保留码点,每个码点唯一对应于一个字符。
因此,除开非字符码点和保留码点,码点值(即码点编号)通常来说就是其所对应的字符的编号,所以码点值有时也可以直接称之为字符编号,虽然不够准确,但更为直接。
字符集中所有码点数量的总和,称之为编号空间(Code Space,又被称之为代码空间、编码空间、码点空间、码空间)。
码点值最初用两个字节的十六进制数字表示,比如字母A的Unicode码点值为0041,常写作U+0041,这种形式称为Unicode码点名称,不严格地来讲,也可称之Unicode字符名称(因为存在着非字符码点和保留码点,并非每个码点都分配了字符,所以这种称呼不够准确,不过目前更为普遍)。
后来随着Unicode字符集的不断增补扩大(比如现在的Unicode字符集至少需要21位才能全部表示),码点值也扩展为用三个字节或以上的十六进制数字表示。
例如,ASCII字符集用0~127这连续的128个数字编号分别表示128个字符。GBK字符集使用区位码的方式为每个字符编号,首先定义一个94×94的矩阵,行称为“区”,列称为“位”,然后将所有国标汉字放入矩阵当中,这样每个汉字就可以用唯一的“区位”码来标识了。例如“中”字被放到54区第48位,因此其区位码(字符编号)就是5448。
而目前Unicode标准中,将字符按照一定的类别划分到0~16这17个平面(Plane层面)中,每个平面中拥有2^16 = 65536个码点,因此,目前Unicode字符集所拥有的码点总数,也就是Unicode的编号空间为17*65536=1114112。
image.png
三、码元
在计算机存储和网络传输时,码点值(即字符编号)被映射到一个或多个码元(Code Unit代码单元、编码单元)。
码元可理解为字符编码方式CEF(Character Encoding Form)对码点值进行编码处理时作为一个整体来看待的最小基本单元(基本单位)。
为什么非要引入“码元”这个概念?或者说,为什么非要强调“码元”这个概念?
码元某种程度上可认为对应于高级语言中的基本数据类型。而高级语言层面的基本数据类型,若要更深入一步地来讲,实质上对应于机器硬件层面(如汇编语言层面)的数据类型byte字节、word字、dword双字等在硬件中的表达与处理机制。
之所以要强调“码元”的概念,是因为字符编码作为一串数字序列,最终还是得通过机器硬件层面的数据类型来表示。
而码元的实质,就是机器硬件层面(如汇编语言层面)的数据类型;不同的码元,代表着不同位数的数据类型。当字符编码方案设计人员基于某种考虑为某种字符编码方式CEF选择了某种码元时,其实质是选择了某种数据类型。
数据类型,在机器硬件层面上来看,只有作为一个整体来处理的二进制数字位数上的不同(如byte字节、word字、dword双字等汇编语言的数据类型,都是二进制数字值,只是位数不同而已),而并无高级语言层面数字、布尔、字符等语义上的不同,毕竟本质上计算机只“认识”由0和1组成的数字。
正因为如此,字符编码方案设计人员在设计字符编码方式CEF时,必然要选择一种机器硬件层面上某个位数的数据类型(如byte字节、word字、dword双字等)来表示,不是选择这个位数的数据类型,就是选择那个位数的数据类型。当然这种选择会基于各种考虑(如时间复杂度、空间复杂度等),而一旦选择了某种数据类型,其所选择的数据类型位数上的不同,同时也就体现为了码元位数上的不同。
所以,不同位数的码元,实质上是机器硬件层面上不同位数的数据类型。(而机器硬件层面上为什么要设计不同位数的多种数据类型出来,这是另一个话题了,有兴趣可参看有关硬件方面的专著。)
数据类型有单字节与多字节之分,所以码元也有单字节与多字节之分;多字节数据类型由于历史的原因,存在着字节序的所谓大端序(Big-Endian)与小端序(Little-Endian)之分,因此多字节码元也存在着大端序与小端序之分(具体详见前文中有关字节序的解释;注意,单字节数据类型则没有字节序的问题,所以单字节码元也就没有字节序问题)。
这就是之所以要强调“码元”这个概念的关键原因。
对码点值(即字符编号)进行编码的具体实现方式——字符编码方式CEF,就是由一个或多个码元这样的最小基本单元构成的。
最常用的码元是8位(1字节)的单字节码元,另外还有16位(2字节)和32位(4字节)两种多字节码元,分别相当于汇编语言中的yte字节、word字、dword双字,以及C++中的无符号整型BYTE、WORD、DWORD。
(注: 在VC++6.0中,这三种数据类型的定义分别为:
typedef unsigned char BYTE; // 1个字节
typedef unsigned short WORD; // 2个字节
typedef unsigned long DWORD; // 4个字节)
于是,三种码元对应就有了Unicode字符编号(码点值)的三种UTF编码方式(即Unicode码转换格式Unicode Transformation Format,或称通用字符集转换格式UCS Transformation Format):
UTF-8(8-bit Unicode/UCS Transformation Format),
UTF-16(16-bit Unicode/UCS Transformation Format),
UTF-32(32-bit Unicode/UCS Transformation Format);
或者反过来说,Unicode字符编号(码点值)的三种UTF编码方式(UTF-8、UTF-16、UTF-32)分别采用了不同的码元(BYTE、WORD、DWORD)来编码。
image.png
注意,这里之所以说是“模拟”,如前文所述,因为从本质上来讲,在机器硬件层面上的所有数据类型,只存在着被视作一个整体来处理的比特序列(比特流)的位数不同之分,不存在着高级语言层面上数据类型的数值、字符串、布尔值等的语义不同之分。
因此,机器硬件层面上的数据类型与高级语言层面上的数据类型,严格来讲,在本质含义上还是有着很大不同的。当然,高级语言层面上的数据类型最终还是会被转化为机器硬件层面上的数据类型,毕竟计算机只“认识”由0和1所组成的比特流。(可同时参见前文中有关字节序的解释)
这里用BYTE、WORD、DWORD分别表示无符号8位整数、无符号16位整数和无符号32位整数;因而UTF-8、UTF-16、UTF-32可认为分别以BYTE、WORD、DWORD作为码元。
“汉字”这两个中文字符的UTF-8编码需要六个BYTE(共6个单字节码元),大小是6个字节;UTF-16编码需要两个WORD(共2个双字节码元),大小是4个字节;UTF-32编码需要两个DWORD(共2个四字节码元),大小是8个字节。
由于多字节数据类型的数据在计算机存取时存在一个字节序的问题,因此,UTF-16、UTF-32这两种编码方式所编码出来的逻辑意义上的多字节码元序列,在映射为物理意义上的字节序列时,字节序列的字节序因系统平台的不同而不同。
前面已经多次强调过了,这里再次特别强调一下:由单字节数据类型所组成的多字节数据是不存在字节序的问题的。因此,采用单字节码元进行编码的UTF-8编码,虽然ASCII字符为单字节编码,而非ASCII字符却是多字节编码的,但却并不存在字节序问题,这是跟同样为多字节编码、但采用多字节码元的UTF-16、UTF-32编码的不同之处。详见下表所列:
image.png
(十)UTF-8编码方式与字节序标记
一、UTF-8编码方式
接下来将分别介绍Unicode字符集的三种编码方式:UTF-8、UTF-16、UTF-32。这里先介绍应用最为广泛的UTF-8。
为满足基于ASCII、面向字节的字符处理的需要,Unicode标准中定义了UTF-8编码方式。UTF-8应该是目前应用最广泛的一种Unicode编码方式(但不是最早面世的,UTF-16要早于UTF-8面世)。它是一种使用8位码元(即单字节码元)的变宽(即变长或不定长)码元序列的编码方式。
由于UTF-16对于ASCII字符也必须使用两个字节(因为是16位码元)进行编码,存储和处理效率相对低下,并且由于ASCII字符经过UTF-16编码后得到的两个字节,高字节始终是0x00,很多C语言的函数都将此字节视为字符串末尾从而导致无法正确解析文本。
因此,UTF-16一开始推出的时候就遭到很多西方国家的抵制,大大影响了Unicode的推行。于是后来又设计了UTF-8编码方式,才解决了这些问题。
UTF-8的码元由8位单字节组成;在UTF-8中,因为码元较小的缘故,Unicode码点值被映射到一个、两个、三个或四个码元;换言之,UTF-8使用一个至四个8位单字节码元的序列来表示Unicode字符。
UTF-8编码方式对所有ASCII码点值(0x00~0x7F)具有透明性。所谓透明性,具体指的是在U+0000到U+007F范围内(十进制为0~127)的Unicode码点值,被直接转换为UTF-8单一字节码元0x00~0x7F,与ASCII码没有区别。
并且,0x00~0x7F不会出现在UTF-8编码的非ASCII字符的首字节与非首字节的任意一个字节中(非ASCII字符的UTF-8编码为由多个单字节码元所组成的码元序列),这样就保证了与早已应用广泛且已成为工业标准的ASCII码的完全兼容,避免了歧义,同时纠错能力也强。
UTF-8同其他的多字节码元编码方式相比具有以下优点:
a) UTF-8的编码空间足够大,未来Unicode新标准收录更多字符,UTF-8也能适应,因此不会再出现UTF-16那样的尴尬。
(注:这里所指的编码空间并不是前文所提到的编号空间Code Space,编号空间属于编号字符集CCS里的概念,而编码空间属于字符编码方式CEF里的概念,两者不能等同;这里的编码空间可理解为编码方式的未来可扩展性、高适应性,详见后文《UTF-8究竟是怎么编码的——UTF-8的编码算法介绍》以及《UTF-16究竟是怎么编码的——UTF-16的编码算法介绍》)
b) UTF-8是变长编码(准确地说是变长码元序列,而码元本身是固定长度为8位单字节的,也就是说,UTF-8采用的是单字节码元),比如一个字节足以容纳所有的ASCII码字符,就用一个字节来存储,不必在高位补0以浪费更多的字节来存储,因此在英语作为国际语言的现实情况下,UTF-8因其ASCII字符的单字节编码这一特性可节省大量存储空间。
c) UTF-8完全直接兼容ASCII码,而非不完全间接兼容。
d) UTF-8的码元序列的第一个字节指明了后面所跟的字节的数目(即带有前缀码),这对字节流的前向解析非常有效(详见后面的附文《UTF-8是怎么编码的——UTF-8的编码算法介绍》)。
e) 也因为UTF-8编码带有前缀码,所以容错性好,即使在传输过程中发生局部的字节错误,比如即便丢失、增加、改变了某些字节,也不会导致所有后续字符全部错乱这样传递性、连锁性的错误问题(否则,若存在错误传递性、连锁性的话,一旦中间某些字节出错,则必须丢弃从出错点开始到结尾的所有编码字节,比如GB码、UTF-32码就是如此),因此很容易重新同步,具有很强的鲁棒性(即健壮性)。
f) 由于UTF-8编码没有状态,从UTF-8字节流的任意位置开始可以有效地找到一个字符的起始位置,字符边界很容易界定、检测出来,所以具有很好的“自同步性”。
g) UTF-8已经成为互联网所采用的字符编码方式的事实标准。
h) UTF-8是字节顺序无关的(因为是单字节码元,而非像UTF-16、UTF-32这样的多字节码元),它的字节顺序在所有系统中都是一样的,其码元序列与字节序列(字节流)相同,因此它实际上并不需要字节顺序标记BOM(Byte-Orde Mark),虽然Windows系统经常“多此一举”地加上BOM。(有关字节序标记BOM的介绍见下文)
字节序问题在进行信息交换时会带来不小的麻烦。如果字节序未协商好,将导致乱码;若协商结果为双方一个采用大端一个采用小端,则必然有一方要进行大小端转换,性能损失不可避免(字节序的大小端问题其实不像看起来那么简单,有时会涉及硬件、操作系统、上层应用软件多个层次,可能会导致多次转换,详见前文中有关字节序Byte-Orde的介绍)。
i) 字节FE(二进制为1111 1110)和FF(二进制为1111 1111)在UTF-8编码中永远不会出现(因为UTF-8编码方式中,每个字节只能以0、110、1110、11110或10开头,详见后文介绍)。因此可以用称之为零宽度不中断空格(ZERO WIDTH NO-BREAK SPACE)的字符(Unicode字符名称为U+FEFF)作为字节顺序标记BOM来标明UTF-16或UTF-32文本的字节序。
(Windows系统中BOM有时也用在UTF-8编码的文本文件的开头,虽然UTF-8编码不存在字节序问题,但Windows却用BOM来表明该文本文件的编码格式为UTF-8,看起来这有点“多此一举”,其具体原因详见后文)
j) UTF-8编码可以通过屏蔽位和移位操作快速读写。
k) 字符串比较时strcmp()和wcscmp()的返回结果相同,因此使排序变得更加容易。
UTF-8编码方式也并非完美无缺,大致上有如下缺点:
a) 无法根据字符数直接判断出UTF-8文本的字节数,因为UTF-8是一种变长编码方式(码元虽然固定为8位单字节,但码元序列是变长的,可能是单个码元共8位,比如ASCII字符;也可能是两个码元共16位、三个码元共24位、四个码元共32位等)。因此,无论是计算字符数,还是执行索引操作,效率都不高。
b) 需要用2个字节编码那些在扩展ASCII(即EASCII)字符集中只需1个字节编码的扩展字符。
c) 以8位单字节码元编码的UTF-8字符会被Email网关过滤,因为Internet上的信息传输最初设计为7位ASCII码字符(ASCII仅用到了1个字节的低7位)的传输。因此产生了UTF-7编码(类似于同样为Email传输而设计的Base64编码或quoted-printable编码,由于Base64编码或quoted-printable编码各有其不足,因此又设计了UTF-7编码)。
d) UTF-8在它的表示中使用值100xxxxx的几率超过50%,而现存的实现如ISO 2022、4873、6429和8859系统,会把它错认为是C1控制码。因此产生了UTF-7.5编码。
二、字节序标记BOM
在将逻辑形式的码元序列(或可称之为逻辑编码)映射为物理形式的字节序列(或可称之为物理编码)时,因系统平台的差异,存在一个字节序(Byte-Order字节顺序)的问题。Unicode/UCS规范中推荐的标记字节顺序的方法是BOM字节序标记(Byte-Order Mark字节顺序标记)。
字节序标记BOM是Unicode码点值为FEFF(十进制为65279,二进制为1111 1110 1111 1111)的字符的别名。
最初,字符U+FEFF如果出现在字节流的开头,则用来标识该字节流的字节序——是高位在前还是低位在前;如果它出现在字节流的中间,则表达为该字符的原义——零宽度不中断空格(ZERO WIDTH NO-BREAK SPACE零宽度无断空白)。该字符名义上是个空格,实际上是零宽度的,即相当于是不可见也不可打印字符(平常使用较多的是ASCII空格字符,是非零宽度的,需要占用一个字符的宽度,为可见不可打印字符)。
从Unicode 3.2开始,U+FEFF只能出现在字节流的开头,且只能用于标识字节序,就如它的别名——字节序标记——所表示的意思一样;除此以外的用法已被舍弃。取而代之的是,使用U+2060来表示零宽度不中断空格。
如果UTF-16编码的字节序列为大端序,则该字节序标记在字节流的开头呈现为0xFE 0xFF;若字节序列为小端序,则该字节序标记在字节流的开头呈现为0xFF 0xFE。如果UTF-32编码的字节序列为大端序,则该字节序标记在字节流的开头呈现为0x00 0x00 0xFE 0xFF;若字节序列为小端序,则该字节序标记在字节流的开头呈现为0xFF 0xFE 0x00 0x00。
UTF-8编码本身没有字节序的问题,但仍然有可能会用到BOM——有时被用来标示某文本是UTF-8编码格式的文本;再强调一遍:在UFT-8编码格式的文本中,如果添加了BOM,则只用它来标示该文本是由UTF-8编码方式编码的,而不用来说明字节序,因为UTF-8编码根本就不存在字节序问题。
许多Windows程序(包含记事本)会添加BOM到UTF-8编码格式的文件中(至于为什么要添加BOM,可参看《微软跟联通有仇?。然而,在类Unix系统中,这种作法则不被建议采用。
因为它会影响到无法识别它的编程语言,如gcc会报告源码文件开头有无法识别的字符;而在PHP中,如果没有激活输出缓冲(output buffering),它会使得页面内容开始被送往浏览器(即header头被提交),这使PHP脚本无法指定header头(HTTP Header)。
对于已在IANA注册的字符编码(实际为字符编码模式CES)UTF-16BE、UTF-16LE、UTF-32BE和UTF-32LE等来说,不可使用BOM。因为其名称本身已决定了其字节顺序。对于已注册的字符编码UTF-16和UTF-32来说,则必须在文本开头使用BOM。
image.png三、小结
由于UTF-8编码方式以一个字节(8位)作为码元,属于单字节码元,在计算机处理、存储和传输时不存在字节序问题(字节序问题只跟多字节码元有关),因此避免了平台依赖性,跨平台兼容性好。
它相对于其他编码方式对英语更为友好,同样也对计算机语言(如C++、Java、C#、JavaScript、PHP、HTML等)更为友好。它在处理ASCII等常用字符集时很少会比UTF-16低效。
所以,UTF-8是较为平衡、较为理想的Unicode编码方式。虽然Windows平台由于历史的原因API缺乏对UTF-8的原生支持(Windows原生支持的是UTF-16,因为UTF-16早于UTF-8面世),导致UTF-8推出后的早期使用不广,但目前是应用最为广泛的三大UTF编码方式之一。
因此,应该尽量使用UTF-8(准确地说,应该尽量使用UTF-8 without BOM,即不带字节顺序标记BOM的UTF-8)。
(十一)UTF-8究竟是怎么编码的
UTF-8编码是Unicode字符集的一种编码方式(CEF),其特点是使用变长字节数(即变长码元序列、变宽码元序列)来编码。一般是1到4个字节,当然,也可以更长。
为什么要变长呢?这可以理解为按需分配,比如一个字节足以容纳所有的ASCII字符,那何必补一堆0用更多的字节来存储呢?
实际上变长编码有其优势也有其劣势,优势是节省空间、自动纠错性能好、利于传输、扩展性强,劣势是不利于程序内部处理,比如正则表达式检索;而UTF-32这样等长码元序列(即等宽码元序列)的编码方式就比较适合程序处理,当然,缺点是比较耗费存储空间。
那UTF-8究竟是怎么编码的呢?也就是说其编码算法是什么?
UTF-8编码最短的为一个字节、最长的目前为四个字节,从首字节就可以判断一个UTF-8编码有几个字节:
如果首字节以0开头,肯定是单字节编码(即单个单字节码元);
如果首字节以110开头,肯定是双字节编码(即由两个单字节码元所组成的双码元序列);
如果首字节以1110开头,肯定是三字节编码(即由三个单字节码元所组成的三码元序列),以此类推。
另外,UTF-8编码中,除了单字节编码外,由多个单字节码元所组成的多字节编码其首字节以外的后续字节均以10开头(以区别于单字节编码以及多字节编码的首字节)。
0、110、1110以及10相当于UTF-8编码中各个字节的前缀,因此称之为前缀码。其中,前缀码110、1110及10中的0,是前缀码中的终结标志。
UTF-8编码中的前缀码起到了很好的区分和标识的作用——当解码程序读取到一个字节的首位为0,表示这是一个单字节编码的ASCII字符;当读取到一个字节的首位为1,表示这是一个非ASCII字符的多字节编码字符中的某个字节(可能是首字节,也可能是后续字节),接下来若继续读取到一个1,则确定为首字节,再继续读取直到遇见终结标志0为止,读取了几个1,就表示该字符为几个字节的编码;当读取到一个字节的首位为1,紧接着读取到一个终结标志0,则该字节显然是非ASCII字符的后续字节(即非首字节)。
所以,1~4字节的UTF-8编码看起来分别是这样的:
image.png
单字节可编码的Unicode码点值范围十六进制为0x0000 ~ 0x007F,十进制为0 ~ 127;
双字节可编码的Unicode码点值范围十六进制为0x0080 ~ 0x07FF,十进制为128 ~ 2047;
三字节可编码的Unicode码点值范围十六进制为0x0800 ~ 0xFFFF,十进制为2048 ~ 65535;
四字节可编码的Unicode码点值范围十六进制为0x10000 ~ 0x1FFFFF,十进制为65536 ~ 2097151(目前Unicode字符集码点编号的最大值为0x10FFFF,实际尚未编号到0x1FFFFF;这说明作为变长字节数的UTF-8编码其未来扩展性非常强,即便目前的四字节编码也还有大量编码空间未被使用,更不论还可扩展为五字节、六字节……)。
上述Unicode码点值范围中十进制值127、2047、65535、2097151这几个临界值是怎么来的呢?
因为UTF-8编码中的每个字节中都含有起到区分和标识之用的前缀码0、110、1110以及10之一,所以1~4个字节的UTF-8编码其实际有效位数分别为8-1=7位(2^7-1=127)、16-5=11位(2^11-1=2047)、24-8=16位(2^16-1=65535)、32-11=21位(2^21-1=2097151),如下表所示:
image.png
注:上图中的Unicode range即Unicode码点值范围(也就是Unicode码点编号范围),Hex为16进制,Binary为二进制;Encoded bytes即UTF-8编码中各字节的编码方式(即编码算法),其中,x代表Unicode二进制码点值的单字节或低字节中的低7位或8位、y代表两字节码点值的高字节中的低3位或8位以及三字节码点值的中字节中的8位、z代表三字节码点值的高字节中的低5位。
由于ASCII字符的UTF-8编码使用单字节,而且和ASCII编码一模一样,这样所有原先使用ASCII编码的文档就可以直接解码了,无需进行任何转换,实现了完全兼容。考虑到计算机世界中英文文档的数量之多,这一点意义重大。
而对于其他非ASCII字符,则使用2~4个字节的编码来表示。其中,首字节中前置的1的个数代表该字符编码的字节数(110代表两个字节、1110代表三个字节,以此类推),非首字节之外的剩余字节的高2位始终是10,这样就不会与ASCII字符编码以及非ASCII字符的首字节编码相冲突。
例如,假设某个字符的首字节是1110yyyy,前置有三个1,说明该字符编码总共有三个字节,必须和后面两个以10开头的字节结合才能正确解码该字符。
由此可知,UTF-8编码设计得非常精巧,虽说不上完美无缺,但若与后文将要介绍的UTF-16、UTF-32以及前文介绍过的那些ANSI编码相比较,对于其精巧设计将体会得更为深切透彻。因此,UTF-8越来越得到全球一致认可,大有一统字符编码之势。
网友评论