接下来将分别介绍Unicode字符集的三种编码方式:UTF-8、UTF-16、UTF-32。这里先介绍应用最为广泛的UTF-8。
——维基百科
由于UTF-16对于ASCII字符也必须使用两个字节(因为是16位码元)进行编码,存储和处理效率相对低下,并且由于ASCII字符经过UTF-16编码后得到的两个字节,高字节始终是0x00,很多C语言的函数都将此字节视为字符串末尾从而导致无法正确解析文本。
因此,UTF-16一开始推出的时候就遭到很多西方国家的抵制,大大影响了Unicode的推行。于是设计了UTF-8编码方式,才解决了这些问题。
ps:按个人理解纠正下原文:UTF-16编码1996年才得以公布,且微软Windows2000以后才使用UTF-16,之前实质为UCS-2。所以上文中的UTF-16实指UCS-2。
UTF-8的码元由8位单字节组成;在UTF-8中,因为码元较小的缘故,Unicode码点值被映射到一个、两个、三个或四个码元;换言之,UTF-8使用一个至四个8位单字节码元的序列来表示Unicode字符。因此,UTF-8是一种使用单字节码元的变宽(即变长或不定长)码元序列的编码方式。
UTF-8编码方式对所有ASCII码点值(0x00~0x7F)具有透明性。所谓透明性,具体指的是在U+0000到U+007F范围内(十进制为0~127)的Unicode码点值,亦即ASCII字符的Unicode码点值,被直接转换为UTF-8单一字节码元0x00~0x7F,与ASCII码没有区别。
并且,0x00~0x7F不会出现在UTF-8编码的非ASCII字符的首字节与非首字节的任意一个字节中(非ASCII字符的UTF-8编码为由两个或两个以上的单字节码元所组成的码元序列),这样就保证了与早已应用广泛且已成为工业标准的ASCII编码的完全兼容,且避免了歧义,同时纠错能力也强。
1992年初,为创建良好的字节串编码系统以供多字节字符集使用,开始了一个正式的研究。
RFC在成为标准或标准的一部分之前,首先成为互联网草案,通常在经过若干次修订后被RFC编辑所接受被标注为提名标准。在此之后,RFC可以被标注为互联网标准。
目前有好几份关于UTF-8详细规格的文件,但这些文件在定义上有些许的不同:
它们取代了以下那些被淘汰的定义:
事实上,所有定义的基本原理都是相同的,它们之间最主要的不同是支持的字符范围及无效输入的处理方法。
UTF-8同其他的多字节码元编码方式相比具有以下优点:
由于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-8withoutBOM,即不带字节顺序标记BOM的UTF-8)。
UTF-8编码方式也并非完美无缺,大致上有如下缺点:
UTF-8编码是Unicode字符集的一种字符编码方式(CEF),其特点是使用变长字节数(即变长码元序列或称变宽码元序列)来编码。目前一般是1到4个字节。
为什么要变长呢?这可以理解为按需分配,比如一个字节足以容纳所有的ASCII字符,那何必补一堆0,导致占用更多的字节来存储呢?
实际上变长编码有其优势,也有其劣势,优势方面除了上面所讲的节省存储空间之外,还有就是自动纠错性能好、利于传输、扩展性强,而劣势方面主要是由于字符的编码字节数不固定导致不利于程序内部处理,比如导致正则表达式检索的复杂度大为增加;而UTF-32这样的等长码元序列(即等宽码元序列)的编码方式就比较适合程序处理,当然,缺点是比较耗费存储空间。
那UTF-8究竟是怎么编码的呢?也就是说其编码算法是什么?
UTF-8编码最短的为一个字节、最长的目前为四个字节,从首字节就可以判断一个UTF-8编码有几个字节:
另外,UTF-8编码中,除了单字节编码外,由多个单字节码元所组成的多字节编码其首字节以外的后续字节均以10开头(以区别于单字节编码以及多字节编码的首字节)。
0、110、1110以及10相当于UTF-8编码中各个字节的前缀,因此称之为前缀码。其中,前缀码110、1110及10中的0,是前缀码中的终结标志。
UTF-8编码中的前缀码起到了很好的区分和标识的作用:
所以,1~4字节的UTF-8编码看起来分别是这样的:
单字节可编码的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)。如下表所示:
**注:*上图中的Unicoderange为Unicode码点值范围(也就是Unicode码点编号范围),Hex为16进制,Binary为二进制;Encodedbytes为UTF-8编码中各字节的编码方式(即编码算法),其中,x代表Unicode二进制码点值的单字节或低字节中的低7位或8位、y代表两字节码点值的高字节中的低3位或8位以及三字节码点值的中字节中的8位、z代表三字节码点值的高字节中的低5位。
因此,UTF-8编码的算法简单地来概括就是:首先确定UTF-8编码中各个字节的前缀码;之后再将UTF-8编码中各个字节除了前缀码所占用之外的位,依次分配给Unicode字符码点值二进制中各个位的值。换言之,就是用Unicode字符码点值二进制中各个位的值,依次填充UTF-8编码中的各个字节除了前缀码所占用之外的位。
由于ASCII字符的UTF-8编码使用单字节,而且和ASCII编码一模一样,这样所有原先使用ASCII编码的文档就可以直接解码了,无需进行任何转换,实现了完全兼容。考虑到计算机世界里的英文文档数量之多,这一点意义重大。
而对于其他非ASCII字符,则使用2~4个字节的编码来表示。其中,首字节中前置的“1”的个数代表该字符编码的字节数(如110代表两个字节、1110代表三个字节,以此类推),非首字节之外的剩余后续字节的前两位始终是10,这样就不会与ASCII字符编码(“0”开头)以及非ASCII字符的首字节编码(110或1110等至少两个“1”开头)相冲突。
例如,假设某个字符的首字节是1110yyyy,前置有三个1,说明该字符编码总共有三个字节,必须和后面两个以10开头的字节结合才能正确解码该字符。
由此可知,UTF-8编码设计得非常精巧,虽说不上完美无瑕,但若与后文将要介绍的UTF-16、UTF-32以及前文介绍过的那些ANSI编码相比较,对于其精巧设计将体会得更为深切透彻。因此,UTF-8越来越得到全球一致认可,大有一统字符编码之势。
由于UTF-8编码方式以一个字节(8位)作为码元,属于单字节码元,在计算机处理、存储和传输时不存在字节序问题(字节序问题只跟多字节码元有关。ps:记住定义:UTF-8是单字节码元可变长编码方式),因此避免了平台依赖性,跨平台兼容性好。
它相对于其他编码方式对英语更为友好,同样也对计算机语言(如C++、Java、C#、JavaScript、PHP、HTML等)更为友好。
所以,**UTF-8是较为平衡、较为理想的Unicode编码方式。**虽然Windows平台由于历史的原因API缺乏对UTF-8的原生支持(Windows原生支持的是UCS-2,后升级为UTF-16。因为UCS-2早于UTF-8面世,详细见《1.6Unicode编码系统>发展节点》),导致UTF-8推出后的早期使用不广,但目前是应用最为广泛的三大UTF编码方式之一。
1988年成立的Unicode团队和1989年成立的UCS团队。等到他们发现了对方的存在,很快就达成一致:世界上不需要两套统一字符集。
1991年10月,两个团队决定合并字符集。也就是说,从今以后只发布一套字符集,就是Unicode,并且修订此前发布的字符集,UCS的码点将与Unicode完全一致。
但是由于UCS的开发进度快于Unicode,1990年就公布了第一套编码方法UCS-2,使用2个字节表示已经有码点的字符。(那个时候只有一个平面,就是基本平面,所以2个字节就够用了。)UTF-16编码迟至1996年7月才公布,明确宣布是UCS-2的超集,即基本平面字符沿用UCS-2编码,辅助平面字符定义了4个字节的表示方法,即代理区。
ps:Windows2000(2000年7月31日)及之后的版本是支持UTF-16的,之前的WindowsNT/95/98/ME(1993年7月27日)是只支持UCS-2的。
UCS-2,是早期遗留下来的历史产物。其将字符编号直接映射为字符编码(CEF,而非CES,详见前文中对现代字符编码模型的解释),亦即字符编号就是字符编码,中间没有经过特别的编码算法转换。因此,从现代字符编码模型的角度来看的话,此时并没有将编号字符集CCS与字符编码方式CEF作严格区分,既可以将UCS-2看作是编号字符集CCS中的字符编号,也可以看作是字符编码方式CEF中的字符编码。
后来,随着Unicode联盟与ISO/IEC就创建全球统一的单一通用字符集进行合作,Unicode字符集与UCS字符集逐渐相互融合,两者最终基本保持了一致。
这之后,Unicode逐渐占据了主导地位(据说是因为Unicode这个名称好记。。。),并引入了UTF-16编码方式。为什么要引入UTF-16编码方式呢?
前文已经介绍过了,Unicode字符集(CCS)到目前为止定义了包括1个基本平面BMP和16个增补平面(即辅助平面)在内的共17个平面。
每个平面的码点数量为2^16=65536个,因此17个平面的码点总数为共65536x17=1114112个。其中,基本平面码点为65536个(码点编号范围为0x0000~0xFFFF),增补平面码点为1114112-65536=65536x16=1048576个(码点编号范围为0x10000~0x10FFFF)。
很明显,简单地用一个16位码元肯定无法表示所有17个平面的这么多码点(因为2^16=65536,而码点总数为65536*17=1114112)。而UCS-2,正是用固定两个字节共16位来表示一个字符的,其直接使用Unicode码点值来表示基本平面BMP码点范围。为支持字符编号超过U+FFFF的增补字符,扩展势在必行。
UCS因而又提出了UCS-4(与UCS-2同是1990年先后,用来容纳下汉字),即用四个字节共32位来表示一个字符(此时UCS-4同样既可认为是编号字符集CCS中的字符编号,也可认为是字符编码方式CEF中的字符编码,即同样直接使用Unicode码点值来表示所有字符),但码元也因此从16位扩展到了32位。
而Unicode却提出了不同的扩展方式—代理机制。具体而言,就是为了能以一个统一的16位码元同时编码基本平面以及增补平面中的字符码点编号,Unicode设计引入了UTF-16编码方式,并且通过代理机制实现了扩展。
UTF-16编码方式的引入,从现代字符编码模型的角度来看的话,彻底将编号字符集CCS与字符编码方式CEF作了严格区分。也就是说,在UTF-16编码方式中,编号字符集CCS中的字符编号与字符编码方式CEF中的字符编码不再仅仅是简单的直接映射关系。
具体来说,就是Unicode字符集基本平面BMP中的字符(大致相当于UCS字符集中的UCS-2字符,但必须除开U+D800~U+DFFF这一在Unicode字符集BMP中称之为代理码点的部分),仍然是直接映射关系,亦即这部分字符的字符编号与字符编码是等同的。
但Unicode字符集增补平面中的字符(大致相当于UCS字符集UCS-4字符中除开UCS-2字符的部分,因为广义上的UCS-4字符实际上包含了UCS-2字符,当然狭义上的UCS-4字符不包括UCS-2字符),却不是直接映射关系,而是必须通过代理机制这一编码算法的转换,亦即这部分字符的字符编号与字符编码不是等同的。
因此,在Unicode引入了UTF-16编码方式之后,站在现代字符编码模型的角度上来看的话,再将UCS-2和UCS-4直接称之为字符编码方式CEF已不是很合适,更多的应该是编号字符集CCS中的概念(当然,在了解其历史原因之后,将UCS-2和UCS-4同时理解为编号字符集CCS和字符编码方式CEF也未尝不可)。
UTF-16中的所谓代理机制,实际上就是用两个对应于基本平面BMP代理区(SurrogateZone)中的码点编号的16位码元来表示一个增补平面码点,这两个用来表示一个增补平面码点的特殊16位码元被称之为代理对(SurrogatePair)
UTF-16编码方式及其代理机制是在Unicode2.0中为支持字符编号超过U+FFFF的增补字符而引入的,于是从此就由UCS-2的等宽(16位)码元序列编码方式(如前文所述,从现代字符编码模型的角度来看的话,UCS-2更多是的编号字符集CCS中的概念,但考虑到其历史原因,称之为字符编码方式CEF亦未尝不可,下同,不再赘述),变成了UTF-16的变宽(16位或32位)码元序列编码方式。不过,码元依然保持了16位不变。
ps:码元是最小,最短的比特位组合。不是字节变宽变多了,码元就变了。
UCS-2所编码的字符集中的U+D800~U+DFFF这部分代理码点除外的话,UTF-16所编码的字符集可看成是UCS-2所编码的字符集的父集。
在没有引入增补平面字符之前,UTF-16与UCS-2(U+D800~U+DFFF这部分代理码点除外)的编码完全相同。但当引入增补平面字符后,UTF-16与UCS-2的编码就不完全相同了(事实上,由于UCS-2只有两个字节,根本无法编码增补平面字符)。
现在若有软件声称自己支持UCS-2编码,那相当于是在暗示其仅支持UCS字符集或Unicode字符集中的基本平面字符,而不能支持增补平面字符。
所以说,UTF-16是变长编码方式,每个字符编码为16位或32位;而UCS-2是定长编码方式,每个字符编码固定为16位。但两者的码元却都是16位的(而UTF-32和狭义的UCS-4的码元都是32位的)。
另外,UTF-16中,大部分汉字采用两个字节编码,少量不常用汉字采用四个字节编码。
UTF-16一方面使用变长码元序列的编码方式,相较于定长码元序列的UTF-32算法更复杂(甚至比同样是变长码元序列的UTF-8也更为复杂,因为引入了独特的代理对这样的代理机制);另一方面仍然占用过多字节,比如ASCII字符也同样需要占用两个字节,相较于UTF-8更浪费空间和带宽。
首先要注意的是,代理Surrogate是专属于UTF-16编码方式的一种机制,UTF-8和UTF-32是不用代理的。
如前文所述,为了让UTF-16能继续编码基本平面后面的增补平面中的码点值,于是扩展了UTF-16编码方式。
具体的扩展方法就是为其增加了代理机制,用两个对应于基本平面码点(即BMP代理区中的码点)的16位码元来表示一个增补平面码点,这两个用来表示一个增补平面码点的特殊16位码元就被称为“代理对”。
如果要用简单的一句话来概括,就是:所有大于0xFFFF的码点值(即增补平面码点编号,范围为0x10000~0x10FFFF,十进制为65536~1114111;注意,0xFFFF是十六位二进制数的最大值的十六进制表示)要编码成UTF-16编码方式的话,就必须使用代理机制(也就是用代理对来表示)。
在UTF-16编码方式中,被合起来称为“代理对”的这两个16位码元就其中的任一单个码元而言,其实就直接对应于基本平面BMP中的某一个码点(即BMP中每一个码点的值必然对应于一个16位码元的值,因为基本平面中的码点总数为2^16=65536个,而16位码元能表示的值也等于2^16=65536个)。
这样一来,就产生了冲突:某个UTF-16码元到底是用于表示基本平面字符的码元,还是用于表示增补平面字符的代理对中的代理码元?
因此,为避免冲突,这些被用作“代理”的任一码元所对应的码点在基本平面中均未定义字符,即均没有指定字符。且形成“代理对”的两个码元所对应的码点其编号必定是连续的。
“代理”的真实含义或许就在于此:用两个基本平面中未定义字符的连续码点合起来“代为署理”增补平面中的码点。
因此,基本平面中这些用作“代理”的码点区域就被称之为“代理区(SurrogateZone)”,其码点编号范围为0xD800~0xDFFF(十进制55296~57343),共2048个码点。
增补平面一共有16个平面(即第2平面~第17平面),码点编号范围为0x10000~0x10FFFF(十进制为65536~1114111,码点总数为1048576个)。用两个代理码元表示,第一个码元的取值范围为0xD800~0xDBFF(二进制为1101100000000000~1101101111111111,十进制为55296~56319),第二个码元的取值范围为0xDC00~0xDFFF(二进制为1101110000000000~1101111111111111,十进制为56320~57343)。
因此,增补平面的第一个码点的编号0x10000其UTF-16编码就是0xD8000xDC00(即0x10000经UTF-16编码后的码元序列为0xD8000xDC00),其余类推。展现为二进制形式后如下:
其中代理码元1中的110110、代理码元2中的110111是定数(同时也是标志位),p、x是变数。去掉定数后组合起来就是ppppxxxxxxxxxxxxxxxx,共20位(2^20=1048576),刚好能够表示目前16个增补平面中的全部码点(0x10000~0x10FFFF,共1048576个)。其中pppp共4位,表示16个增补平面之一的编号(2^4=16);紧接着的16位x表示某个增补平面内的某个码点(2^16=65536个码点,而65536个码点*16个平面=1048576个码点)。
按照上面的编码方式,代理对里面的两个代理码元分别称之为高16位代理码元(或称为leadsurrogates引导代理、前导代理),和低16位代理码元(或称为trailsurrogates尾随代理、后尾代理)。
由于引导代理和尾随代理的值分别在0xD800~0xDBFF(十进制为55296~56319)之间和0xDC00~0xDFFF(十进制为56320~57343)之间,所以首尾两个代理总共可以组合出(56319-55296+1)*(57343-56320+1)=1048576个代理对,也就是总共可以表示1048576个增补码点,而目前Unicode标准所确定的16个增补平面的码点总和也就是65536*16=1048576个。
显然,Unicode字符集作为开放式字符集,未来不断增补字符进来,以至于增补平面超过16个,则按目前的UTF-16编码算法是无法编码的。也正是因为如此,UTF-16编码方式的扩展性、适应性是不足的,未来全面被具备高扩展性、高适应性的UTF-8编码方式代替是必然的。
从增补平面的码点值通过基本平面中的代理对编码为增补平面字符的码元序列的具体算法如下:
例如,增补平面中码点值为10437(码点名称为U+10437)的字符():
下表总结了该转换。不同的颜色表示码点值是如何被分布到UTF-16码元序列中的,而由UTF-16编码过程中加入的代理附加位则以不同的红色(亮红色与暗红色)显示:
显然,增补平面中的码点值从0x10000到0x10FFFF,共计0xFFFFF+0x1个,即1,048,576个,刚好也就是需要20位来表示(2^20=1,048,576)。如果用两个16位长的码元组成的序列来表示,意味着引导代理要容纳上述20位中的前10位,尾随代理要容纳上述20位中的后10位。
另外,还要能够根据每个16位码元来直接判断该码元到底是属于引导代理(标志位为前6位110110,还剩下10位,因此总个数为2^10=1024个),还是属于尾随代理(标志位为前6位110111,也剩下10位,因此总个数也是2^10=1024个)。
为避免冲突,因此需要在基本多语言平面BMP中保留未定义Unicode字符的1024+1024=2048个码点,就可以容纳引导代理与尾随代理所需要的编号空间(码点空间、代码空间),也就是16个增补平面所需要的编号空间,共计1024*1024=2^20=1048576个码点。这BMP中的2048个码点对于BMP总计65536个码点来说,仅占3.125%(2048/65536=0.03125)。
在UTF-16编码方式中,引导代理的后面应该是一个尾随代理,而尾随代理的前面就应该是一个引导代理;不能出现一个引导代理的后面是一个非代理的普通UTF-16码元的情况,也不能出现一个引导代理的后面还是一个引导代理的情况。
UTF-16文本(字符串)的最后一个码元不能是引导代理,不允许出现一个尾随代理的前面是一个尾随代理的情况,也不允许出现一个尾随代理的前面是一个非代理的普通UTF-16码元的情况;UTF-16文本(字符串)的第一个码元不能是尾随代理。
而单独的一个代理码元(不管是引导代理还是尾随代理)是不合法的,代理必须以一个“引导代理+尾随代理”编码对(即代理对)的形式出现。
UTF-16的这种“代理对”编码规则保证了文本处理程序能够正确地访问和处理包括了基本平面和增补平面在内的全部UTF-16码元序列,并消除了基本平面字符和增补平面字符之间发生冲突的可能性。
因为引导代理和尾随代理码元被各自规定在一个特定范围内取值,所以很简单的一个原则就是:凡是在代理编码范围内的码元就是“代理”增补平面字符的“代理码元”,否则就是“基本平面BMP字符的码元”。由于BMP中的字符码元和代理码元分别在各自独立的编码范围内进行编码,所以对于一个符合格式规范的UTF-16码元来讲,它必须满足以下条件:
在处理UTF-16文本时,为了确保文本数据的完整性,绝对不能把任意一个代理从代理对中拆出来,也不能在代理对中间插入另一个字符的码元或码元序列。
在UTF-16编码方式里面,一个Unicode字符码点值由一个或两个16位码元编码。所以,如果想在一个UTF-16码元序列里面判断某个码元是属于哪个字符的话,就需要检查那个码元的值,然后根据码元的类型(是否具有代理标志位)决定是否还需要向前或向后检查一个相邻的码元的值(可以不必理会除了前后相邻的两个码元之外的其他码元)。
由于引导代理、尾随代理、BMP字符码元,三者互不重叠,搜索就很简单,这意味着UTF-16具有“自同步”(self-synchronizing)性:通过仅检查一个码元就可以判断当前字符的下一个字符的起始码元,每个字符码元的边界很明确;同时,还具有“非传递”性:单独的一个UTF-16码元出错涉及的只是一个字符,不会传递到文本的其他部分去,因此,即使文本中某些字符数据遭到破坏,其影响也只是局部性的。
UTF-8也有类似优点。但许多早期的编码方式就不是自同步的,比如大多数的多字节编码标准如GBK、Big5等,必须从头开始分析文本才能确定不同字符的码元的边界;也不具有非传递性,局部字符数据被破坏,很可能传递到整个文件,导致整个文件无法正确显示。
因此,UTF-8和UTF-16编码方式所具有的“自同步性”、“非传递性”等特点除了增强抗干扰能力外,也提供了随机访问的能力。
由于在大多数的文本数据中,代理对(即增补平面字符码元序列)出现的概率是很小的,很多情况下处理的还是非代理对(即基本平面字符码元序列),导致许多软件处理代理对的部分往往得不到充分的测试。这导致了一些长期的bug与潜在安全漏洞,甚至有些广为流行、得到良好评价的优秀软件也是如此。
因此,虽然编程时同时考虑文本中可能出现的不同存储长度的字符(基本平面字符是单16位编码,即单码元编码;增补平面字符是双16位编码,即双码元编码)并相应做出不同的处理,会比单纯只考虑16位编码在性能上要逊色一些。但实际上,现有的遵循定长16位编码规范但不能处理代理对的程序只需做很小的一点修改就可以同时处理基本平面字符和增补平面字符的编码了。
另外,需要特别注意的是,虽然Unicode标准规定BMP代理区(U+D800~U+DFFF)的码点值不对应于任何字符,即未作定义,但在UCS-2中,U+D800~U+DFFF是被定义了的,也就是已经用于某些字符了。不过,只要前后两个16位码元不是恰好构成了代理对,许多程序还是能把这些不匹配Unicode标准的字符码元正确地辨识、转换成合规的码元。这种由历史原因造成的码元序列按现在的Unicode标准来看,应算作是编码错误。
作为逻辑意义上的UTF-16编码(码元序列),由于历史的原因,在映射为物理意义上的字节序列时,分为UTF-16BE(BigEndian)、UTF-16LE(LittleEndian)两种情况。比如,“ABC”这三个字符的UTF-16编码(码元序列)为:004100420043;其对应的各种字节序列如下:
Windows平台下(还有lunix)的UTF-16编码(即上述的FFFE410042004300)默认为带有BOM的小端序(即LittleEndianwithBOM)。你可以打开记事本,写上ABC,保存时选择Unicode(这里的Unicode实际上指的是UTF-16LittleEndianwithBOM,即带BOM的UTF-16小端序CES编码)
然后保存,再用UltraEdit编辑器看看它的编码结果:
Windows从NT3.1时代(1993年)开始就采用了UTF-16编码方式(ps:其实是UCS-2),很多流行的编程平台,例如.Net、Java、Qt、JavaScript还有Mac下的Cocoa等都是使用UTF-16作为基础的字符编码。例如代码中的字符串,在内存中相应的字节流就是UTF-16字节序列的。(注意,UTF-16编码在Windows环境中被误用为“widechar”和“Unicode”的同义词)。
总结下UTF-16的优点:
以及UTF-16的缺点:
这一段附文本是原文最后一节,可跳过。
当用一个软件(比如Windows记事本或Notepad++)打开一个文本文件时,它要做的第一件事是确定这个文本文件究竟是使用哪种编码方式保存的,以便于该软件对其正确解码,否则将显示为乱码。
一般软件确定文本文件编码方式的方法有如下三种:
文件头标识一般指的是字节顺序标记BOM(ByteOrderMark),位于文件的最开始。当打开一个文本文件时,就BOM而言,有如下几种情形:
接下来,是见证诡异怪事的时刻。
当你在简体中文版的Windows记事本里新建一个文件,输入“联通”两个汉字之后,保存为一个txt文件。然后关闭,再次打开该txt文件后,你会发现刚才输入并保存的“联通”两个汉字竟然莫名其妙地消失了,取而代之的是几个乱码。如下图所示。
这是为什么呢?难道是微软跟联通有仇吗?
原来,当你用Windows记事本新建一个文本文件时,其编码方式默认为ANSI编码(在简体中文版Windows中实际为GBK编码),没有BOM。
注:Windows系统中的ANSI编码指的是在区域设置中所设置的系统默认编码方式,在简体中文版Windows系统中指的是GBK,即CP936代码页,具体可参看前文《GBK码》
在这种编码方式下,该文本文件仅仅保存了“联通”两个汉字的GB内码的四个字节,如下所示:(左边为十六进制,右边为二进制)。
通过Notepad++的HEX-Editor插件可查看内码(十六进制),如下图所示。
通过UltraEdit的“十六进制编辑”模式也可查看内码(十六进制),如下图所示。
当用记事本再次打开该文本文件时,由于没有BOM,记事本又没有提供显式地提示用户手动选择编码方式的功能,于是就只能隐式地按其推断规则自行推断,推断的结果就是被误认为了这是一个UTF-8编码方式的文件。
为什么会推断错误呢?又为什么会将其编码方式错误地推断为UTF-8呢?
注意,“联通”两个汉字的GB内码,其第一第二个字节的起始部分分别是“110”和“10”,第三第四个字节的起始部分也分别是“110”和“10”,这刚好符合了UTF-8编码方式里的两码元序列的编码算法规则(即与UTF-8的两码元序列“110xxxxx10xxxxxx”中的前缀码“110”和“10”刚好是完全一致的;
让我们按照UTF-8的编码算法规则,将第一个字节的前缀码110去掉,得到“00001”,将第二个字节的前缀码10去掉,得到“101010”,将两者组合在一起,得到“00001101010”,再去掉多余的前导的0(ps:不满足8位自动高位补0,方便存储,计算机最小存储单位是字节),就得到了“01101010"(十六进制为6A),这正好是Unicode字符集里的U+006A,也就是小写字母“j”的码点值。
同理,之后的第三个字节与第四个字节按同样的方法用UTF-8解码之后正好是Unicode字符集里的U+0368,这个字符为“”(抱歉,这里的左双引号貌似被这个字符所影响,看起来像是半角左双引号,而无法正常显示为全角左双引号),很像是上标的一个小c,这应该是个组合字符(组合字符是Unicode字符集中的一种特殊字符,必须与其他字符组合在一起以形成一个新字符,一般不单独使用)。
这就是只有“联通”两个汉字的文本文件没有办法在记事本里被正确解码显示的原因。这里要特别说明的是,在记事本里打开时显示的不是“j”和“”,而是显示为了“”(注意右上角是“”)。
而用UltraEdit打开,如果在设置中选择了“自动检测UTF-8文件”,显示的是“j”和“”组合在一起的字符“j”。注意这个字符不是小写字母“j”,而是小写字母“j”上面的点变成了一个上标的小c,因为U+0368这个字符“”应该是个组合字符,与其前面的小写字母“j”组合在一起而形成了一个新字符——j(再次提醒注意:小写字母“j”上面的点变成了“c”)。
注意:在UltraEdit的早期版本中,没有“自动检测UTF-8文件”这一选项
这里还有一个问题:既然已经推断为了UTF-8,那为什么Windows记事本还是将前两个字节,亦即原本为“联”字的GB内码的那两个字节,显示为了“”这样的乱码,而不是显示为小写字母“j”呢?
我想主要是因为小写字母“j”属于ASCII字符,在UTF-8编码中ASCII字符属于单字节编码,出现在双字节编码中是非正常的,因而被Windows记事本认为是错误编码,而UltraEdit则作了容错处理,仍然将其解读为了小写字母“j”。
而后两个字节,亦即原本为“通”字的GB内码的那两个字节,之所以Windows记事本将其按UTF-8编码的规则解读为了字符“”,那是因为字符“”的UTF-8编码正好就是双字节编码,因此按UTF-8编码的规则去解读的话不属于错误。
其实,用记事本默认的编码方式(ANSI)分别单独保存“联”字和“通”字为两个独立的txt文件,则:
而如果是用记事本默认的编码方式(ANSI)保存“联通通信”四个字,则用记事本、UltraEdit(即便选择的是“自动检测UTF-8文件”的情况下)打开后都可正常显示。
这充分说明,Windows记事本在文件头没有BOM的情况下,只能自行推断,由于“联通”两个汉字保存为ANSI编码方式时,内码只有四个字节,在信息不够充足的情况下(尤其是其内码又刚好符合了UTF-8的编码算法规则),于是被错误地推断为了UTF-8编码方式;当以ANSI编码方式保存的是“联通通信”四个汉字时,内码有八个字节,这时信息较为充足,因此被正确地推断为了ANSI编码方式(在简体中文版Windows中ANSI编码默认为GBK编码)。
上面分析的是Windows系统中采用ANSI编码时没有添加BOM的情况。那么,对于采用非ANSI编码时添加了BOM的情况,是否就万事大吉了呢?其实,添加BOM来标记字符编码表面看起来貌似不错,但实际上经常会带来麻烦,因为它和很多协议、规范并不兼容。
Windows里的软件在采用非ANSI编码时,即便对于根本不存在字节序问题的UTF-8编码默认也会添加BOM,而像Unix、Linux、MacOS等Unix系统对于UTF-8编码都默认不添加BOM。
既然Unix系统都可以不添加BOM,那为什么Windows系统却非要添加BOM呢?这很可能是因为Windows系统有大量普通用户使用,在必须兼容传统ANSI编码的情况下,从用户体验角度考虑而没有采用显式地要求用户手动选择字符编码方式的做法,因此特别依赖于通过BOM来防止隐式地自行推断字符编码方式而出错。
微软这种为了照顾广大普通用户而从用户体验角度出发“好心办坏事”的例子其实还有很多。
因此,在Windows系统中,尽量不要使用记事本来打开并编辑文本文件,尤其是作为程序员,应使用Notepad++或UltraEdit等更为专业的文本文件编辑软件。
这一方面是可以避免出现上述这样的“诡异”错误,另一方面也是为了避免Windows记事本“多此一举”地添加BOM,从而给在与其他系统(比如Unix系统)交流时带来不必要的麻烦。
Windows记事本中,对常用编码方式的命名非常“奇葩”,微软这种自行其是的非标准命名,很是令人费解,现解释如下:
注:如果UTF-8编码不添加BOM,则有两种不添加BOM的编码方式,从而导致隐式地自行推断编码方式更容易出错,上文所介绍的对“联通”推断出错即是明证。当然反过来也说明了Windows记事本对于不添加BOM的UTF-8编码其实同样是支持的,而并非简单粗暴地直接提示错误,这应该是为了兼容Unix系统不添加BOM的做法而不得不采取的策略。只是这样一来,就很难避免陷入左右为难的困境。
ps:原作者在书写这两篇附文时并未考虑win101903版之后已经修复了此问题。win101903之前乃至win7、xp等皆有此问题存在。
JavaScript语言采用Unicode字符集,但是只支持一种编码方法。这种编码既不是UTF-16,也不是UTF-8,更不是UTF-32。上面那些编码方法,JavaScript都不用。
JavaScript用的是UCS-2!(ps:通过前文,咱们知道UCS-2其实就是UTF-16的前身,除了U+D800~U+DFFF代理区,其他完全兼容的。)
那么,为什么JavaScript不选择更高级的UTF-16,而用了已经被淘汰的UCS-2呢?
答案很简单:非不想也,是不能也。因为在JavaScript语言出现的时候,还没有UTF-16编码。
**由于JavaScript只能处理UCS-2编码,造成所有字符在这门语言中都是2个字节,如果是4个字节的字符,会当作两个双字节的字符处理。**JavaScript的字符函数都受到这一点的影响,无法返回正确结果。
同时请注意,JavaScript引擎内部是自由的使用UCS-2或者UTF-16。而大部分JS引擎使用的是UTF-16,无论它们使用什么方式实现,它只是一个具体的实现,这不将影响到语言的特性。
以字符
上面代码表示,JavaScript认为字符
要解决这个问题,必须对码点做一个判断,然后手动调整。下面是正确的遍历字符串的写法。
类似的问题存在于所有的JavaScript字符操作函数。
String.prototype.replace()String.prototype.substring()String.prototype.slice()...上面的函数都只对2字节的码点有效。要正确处理4字节的码点,就必须逐一部署自己的版本,判断一下当前字符的码点范围。
JavaScript的下一个版本ECMAScript6(简称ES6),大幅增强了Unicode支持,基本上解决了这个问题。
(1)正确识别字符
ES6可以自动识别4字节的码点。因此,遍历字符串就简单多了。
for(letsofstring){//...}但是,为了保持兼容,length属性还是原来的行为方式。为了得到字符串的正确长度,可以用下面的方式。
Array.from(string).length(2)码点表示法
JavaScript允许直接用码点表示Unicode字符,写法是"反斜杠+U+码点"(注意:里面不需要添加+号)。
'好'==='\u597D'//true但是,这种表示法对4字节的码点无效。ES6修正了这个问题,只要将码点放在大括号内,就能正确识别。
(3)字符串处理函数
ES6新增了几个专门处理4字节码点的函数。
(4)正则表达式
ES6提供了u修饰符,对正则表达式添加4字节码点的支持。
(5)Unicode正规化
Unicode提供了两种表示方法。一种是带附加符号的单个字符,即一个码点表示一个字符,比如ǒ的码点是U+01D1;另一种是将附加符号单独作为一个码点,与主体字符复合显示,即两个码点表示一个字符,比如ǒ可以写成O(U+004F)+ˇ(U+030C)。
//方法一'\u01D1'//'ǒ'//方法二'\u004F\u030C'//'ǒ'这两种表示方法,视觉和语义都完全一样,理应作为等同情况处理。但是,JavaScript无法辨别。
因此,目前UTF-32是一种固定宽度(也称为等宽、等长或定长)码元序列的Unicode字符编码方式。
UTF-32中的码元由32位组成。UTF-32使用的32位码元足够大,目前Unicode字符集中所收录的每个字符的码点值都可直接映射为单个码元。
换言之,UTF-32使用一个32位的码元序列来表示Unicode字符(严格地说,是单个32位的码元,并没有形成两个或两个以上码元所组成的码元序列,除非未来Unicode码点值扩展到64位,这样才可能出现由两个32位的码元所组成的序列)。
因此,即使是ASCII字符,同样需要占用32位(即四个字节)。这在三大UTF编码方式中无疑是最为浪费存储空间的;不过,由于UTF-32是定长编码(UTF-8和UTF-16都是变长编码),因此在文本处理速度上又是三大UTF编码方式中最快的。
由于UTF-32直接以四个字节的码元来表示码点值,这样按目前的情况来看,UCS-4或Unicode增补平面中的所有码点值就都可以完全直接表示,而无需像UTF-16那样使用复杂的代理算法来间接表示。
当然,如前所述,Unicode字符集是一个在不断增加字符的开放字符集,如果未来Unicode字符集的字符编号(即码点值)超过了四个字节,则UTF-32可能也需要像UTF-16一样使用某种特殊编码算法来间接表示。不过,按目前情况来看,真到了那一天,UTF-32编码方式可能也已经完全淘汰了。
与UTF-16类似,作为逻辑意义上的UTF-32码元序列,由于历史的原因,在映射为物理意义上的字节序列时,也分为UTF-32BE大端序、UTF-32LE小端序两种编码模式,因此UTF-32也同样需要使用BOM。
比如,“ABC”这三个字符的UTF-32码元序列为:000000410000004200000043;其对应的各种字节序列如下:
每个UTF-32码元的值与Unicode码点的值完全相同,但其字节序列因字节序的不同而表现为有相同也有不同。
由于UTF-32在三大UTF编码方式中,既不是最早推出的编码方式(最早推出的是UTF-16),也不是最优设计的编码方式(公认为最优设计的是UTF-8),因此在实践中使用得最少,目前几乎已处于淘汰状态。