字符集与编码
作为非英语用户的我们,日常编程中一旦涉及到字符串的处理,我们就必须考虑到字符串的字符集与编码问题。具体说来,就是一个表示字符串的二进制流应该如何解释,哪些片段表示哪个字符。例如,第一位到第十六位是不是表示一个字符,这个字符究竟是什么字符,是“中”,还是“国”,是”B”还是“β”。
此外,现在的字符集和编码也挺多的,什么Unicode,UTF8,GB2312,ISO-8859-1,ANSI,BASE64,application/x-www-form-urlencoded,还有什么带BOM的UTF8之类的,这些词语的关系是什么?都是什么意思呢?
为了把这些问题搞清楚,我把字符集和编码先分开来解释(虽然在很多地方它们几乎是同义词)。
我们可以把字符集(charset)理解为字符的集合,或者更扯远一点,理解为字符到自然数的一一映射。世界上有很多字符,我们在使用数字计算机对字符串进行处理的时候,就必须给每个字符设置一个对应的和唯一的数字。这样,计算机一看到这个数字,就知道是什么字符了。同样,我们输入一个字符的时候,计算机也会将其转化为对应的数字存储下来。
这个对应关系当然是随意的了,只要每个字符对应的数字不一样,而且每个数字对应的字符不一样就行了。在实际使用时,我们可以把字符-数字的对应关系设置得更方便记忆一些。例如在ASCII编码中,英文小写字母a-z的对应数字就是从97-122,大写字母A-Z对应的是65-90。
ASCII编码
数字计算机在美国的发展是最快最早的,当时,美国在世界上的影响力也是最大的。而对于美国人来说,他们常用的字符也就是英文字母,阿拉伯数字,还有一些标点符号了,这些字符加起来也就一百来个,因此美国人发明了ASCII编码,将这些字符都设置好了对应的数字,整出了一个ASCII编码。
把每个字符一一对应到数字还不够。在计算机里,一个数字必须对应到具体的存储量和存储方式。例如我们可以用一个字节(二进制的八位)来保存一个数,也可以用两个字节来保存一个数。如果用一个字节,显然只能对应最多256(
2
)个数。如果用两个字节,可以表示的数倒是多了(65536,即8
2
),但又涉及到高位字节(MSB)和低位字节(LSB)哪个在前,哪个在后的问题。如果高位字节在前,就是大端字节在前序(big-endian),如果低位字节在前,就是小端字节在前序(little-endian)。16
MSB是Most Significant Byte的缩写,LSB是Least Significant Byte的缩写。例如365这个数字,我们将其转化十六进制就是016D,对应两个字节。其中01就是更重要(Significant)的字节,即MSB,而6D则是LSB了。在计算机里016D可以按照自然序保存为01-6D,也可以按照反序保存为6D-01。前者称为big-endian,后者称为little-endian。其中little-endian是Intel CPU的字节序,也是使用更为普遍的字节序。显然,如果我们读和写一个多字节整数的时候必须约定好一致的字节序,不然就会出现混乱了。
好了,言归正传。对于ASCII来说,总共的字符数也不过一百多个,因此他们不必担心多字节的问题,一个字节足够存下这些字符了,而且当时计算机设备昂贵且不可靠,因此一切从简也更好。
多字节编码
但是随着计算机在各国的普及,欧洲还好说,字符数虽多,但是也就几百个,对一种语言来说,也就上百个,有一些急就章的编码方案,如ISO-8859可以用。但是对中国等东亚国家来说,几百个字符是远远不够的,常用汉字就有几千个,必须使用多字节(如GB2312的两字节)的编码方案来做。
但是问题来了,很多现有的软件和文件都是ASCII编码的,因此新的编码方案必须兼容ASCII才行。例如97这个数字已经被ASCII的a这个字符占用了,那咱们就不能把97对应到其他字符了。另外我们在处理字符串的时候,一个字节对应的也必须是ASCII字符(这样才能保证兼容性),两个字节对应的才是我们自己的字符。在技术实现上,我们可以看字节的第一位。如果第一位是0,则表示这是一个ASCII字符,只用读一个字节即可知道对应的ASCII字符。如果第一位是1,则表示这是一个我们的字符,需要再读一个字节得到一个完整的整数,才能对应到具体的字符,例如一个汉字。
在这个时候,各国都定义了自己的字符集和编码标准,例如我国的GB2312,日本的JIS,台湾的Big5等。它们都兼容ASCII编码,但是由于各国只制定自己国家的标准,因此非常有可能出现编码冲突。例如,1616这个数在GBK里对应的是一个汉字,在日文里面可能就是一个片假名了。因此,一段文本给过来之后,我们必须知道这段文本到底是采用的什么编码标准,这样才能正确地将这段文本显示出来,不然就会出现乱码了。
麻烦不止如此,如果我们想在一个文件里既显示中文简体,又显示德语,又显示中文繁体,那就完全没办法了,因为这段文本完全没办法既是这种编码,又是那种编码,就像一个数不能既代表一个汉字,又代表一个片假名一样。因此,这种各国制定自己编码标准的方法,是非常让人郁闷的。
我国的几个编码标准分别是GB2312,GBK与GB18030。每一个编码标准都兼容前一个编码标准,其中GB2312主要编入了常见汉字六千多个,还有拉丁字母等,GBK编入了两万多个汉字以及繁体字等,GB18030则主要加入了少数民族字符。GB2312与GBK是双字节编码,GB18030是变字节编码。例如“中”字的GBK编码就是54992,也就是十六进制的D6D0。
如果按照多少个字节编码一个字符来说,这些编码一般可以分成单字节编码,双字节编码和变字节编码。单字节编码每一个字节对应一个字符,例如ASCII和ISO-8859-1,双字节编码例如GBK和UTF16,变字节编码包括UTF8等。
顺便说一句,在Windows里面常常可以见到所谓的ANSI编码,这实际上就是当前Windows对应的本地编码。例如简体中文Windows的ANSI编码实际上就是GBK编码,而日文Windows中的ANSI编码实际上就是JIS编码,因此ANSI编码并不是指一个固定的编码标准。
Unicode
在这种情况下,Unicode应运而生。Unicode最初是一个私有组织提出的编码标准,后来该阻止将其捐献给国际标准化组织,现在由Unicode联盟维护。Unicode的目的就是为了将所有的字符都纳入统一的编码标准,使得各国文字都能以统一的方式来处理。
Unicode也是兼容ASCII的,而且它的编码范围很大,理论上可以有
2
,即四十亿个以上的字符,就现在看来,应该是很够用了。一般情况下,我们只使用其中的头32
2
字符,即65536个字符即可。其中,简体汉字的范围是19968 ~ 40869,即十六进制的4E00 ~ 9FA5,一共20902个。16
Unicode将所有字符分成了group,plane,row和cell(组,平面,行,单元)四个单位。其中一个cell就是一个字符,256个连续的cell组成一个row,256个row组成一个plane,256个plane组成一个group,256个group组成整个的Unicode字符。因此一个plane就是256*256,即65536个字符,头65536个字符组成的plane叫Basic Multilingual Plane(BMP),也即是我们常用的这些字符所在的plane。
当然,Unicode只是说明了每个字符对应什么数字,但是它还需要更具体一点,那就是每个数字怎么转化为具体的字节,例如如果完全兼容ASCII,肯定需要变长度编码,以一个字节表示ASCII范围内的字符,而以多个字节表示其他的字符。
在Unicode体系里,有多种编码方式,其中包括我们最熟悉的UTF8,还有UTF16 Big-Endian和UTF16 Little-Endian等。
UTF8编码的规范在维基百科上说的很明白,这里就不再赘述。要记住的就是在UTF8编码中,ASCII字符使用一个字节编码,而汉字要使用三个字节编码。例如“中”的Unicode编码为20013,十六进制为4E2D,UTF8编码后为E4B8AD。
所谓的UTF8+BOM编码,就是在一段UTF8编码的文本前面加上BOM,其中BOM是字节序标记(Byte Order Mark)的意思。本来对于UTF8来说是没有字节序的,因为每个字节放在什么地方都是定好的,但是在一段文本前面加上BOM,可以让读取软件马上知道这是一个UTF8编码的文字。BOM使用的是Unicode里没用到的一个字符编码,即FEFF。将其进行UTF8编码得到的是EFBBBF,因此如果一段文本开头是EFBBBF,我们就可以很有把握的说,它是一段UTF8编码的文本了。
对于UTF16来说,它使用两个字节对字符编码,因此涉及到哪个字节在前,那个字节在后的问题,在这个时候BOM才有了用武之地。如果使用Big-Endian,则文本的开头就应该是FEFF,如果使用Little-Endian,则文本的开头就应该是FFFE了。
我们可以使用Windows自带的记事本来保存“中”这个字试试,它的GBK编码是D6D0,Unicode编码是4E2D,UTF8编码是E4B8AD。在记事本里将其保存为ANSI格式,则文件长度为两个字节,内容是D6D0;保存为Unicode格式,则为4个字节,内容是FFFE2D4E(Little-Endian UTF16);保存为Unicode Big Endian格式,则为4个字节,内容是FEFF4E2D;保存为UTF8则是6个字节,内容是EFBBBF E4B8AD。如果手头还有Notepad++,将其保存为UTF8(无BOM)格式,则文件是3个字节,内容是E4B8AD。
Web中使用的编码
以前我遇到的乱码问题一般都是游戏里的文字乱码,虽然也有网页乱码的出现,但是毕竟那时上网也少。现在我们遇到的大部分是Web中的编码问题,在这里单独说说。
首先,每个网页都是文本,因此,网页的字符串也要有自己的编码格式。网页编码的相关问题在我这篇博文了都说过了,就不再废话。
其次,在URL里会遇到很多编码的问题,例如百度搜索和Google搜索的网址里常常会出现%D6%D0和%E4%B8%AD这样的串。这是RFC 3986里面规定的URI编码规范,对于非英文字母,阿拉伯数字和少数几个标点之外的字符,都用百分号+单字节十六进制的方式来对字符进行编码。例如“中”字的GBK编码是D6D0,而UTF8编码是E4B8AD,因此如果网址中有“中”字的话,就需要转化为%D6%D0或者%E4%B8%AD。
对于国内的大部分搜索引擎来说,都是缺省使用GBK编码的,因为这样一个汉字两个字节,网络流量小,传输更快,因此在百度里搜“中”,在地址栏里会出现%D6%D0字样。而对Google这种全球性搜索引擎来说,使用UTF8能兼容各国文字,因此在Google里搜“中”,地址栏里出现的就是%E4%B8%AD了。
百度和Google都支持多种编码。百度虽然缺省使用GBK,但是如果直接把%E4%B8%AD转给它,它也能识别(几年前还需要加额外的参数才能正确处理)。Google虽然缺省是UTF8编码,但是你将关键词换为%D6%D0给它,它也能识别出来。这是因为UTF8字符的编码很有规律,可以比较准确地计算出来一个二进制流是否可能是UTF8编码的。
另外,在电子邮件里常常使用的是BASE64编码,这种编码将每三个字符通过算法转成四个字符,这四个转化后的字符只包含英文字母和阿拉伯数字,这样显示出来会更方便。
注意事项与常见问题
在编码问题上,我们设计系统的时候尽量将所有的相关编码选项都统一为好,不然不知道就会在什么时候出现乱码的问题。一般的做法是将所有的编码都设置为无BOM的UTF8,包括文件编码,数据库编码,网页的参数编码,系统环境编码,编辑器默认编码等。因为现在几乎所有软件都支持UTF8,而且大部分都将UTF8作为默认编码。不选择带BOM的编码是因为在文件拼接(例如使用模板系统时)的时候会导致乱码的问题。
例如下面的PHP代码:
当你将这段代码保存成GBK编码的PHP文件时,它打印的是%D6%D0,当你将其保存为UTF8编码的PHP文件时,它显示的是%E4%B8%AD。这种编码bug非常难查,因此一定要统一编码。
在较老的编程语言中,一般使用的是ASCII编码或者各国制定的本地编码标准。而在现代的大部分语言中,都缺省使用Unicode字符串,一般是仅用到了BMP,亦即2个字节编码的头65536个Unicode字符编码,这种两个字节的字符一般称为宽字符。例如Java,Python 3,C#,javascript等均是如此。
Q:在C语言里如何处理编码问题?
A:在C语言中,传统字符使用char,宽字符使用wchar_t来表示。在Windows的VC开发环境中,还定义了诸多的字符类型,例如TCHAR,会根据项目配置自动使用字符或者宽字符。
在Linux的C环境下,一般使用iconv系列的函数进行字符串的编码转换,例如将一个编码为GBK的字符串转化为编码为UTF8的字符串。
在Windows的VC环境下,一般使用宽字符串作为中转站。例如将一个GBK编码的字符串通过MultiByteToWideChar函数转化为宽字符串,然后再将其通过WideCharToMultiByte转化为UTF8字符串。而Windows下的API,如果有字符串参数的,一般也会有ANSI和Unicode两种版本。例如CreateFile实际上就是根据编译选项被定义成CreateFileA或者CreateFileW。
Q:在Python里要注意哪些编码问题?
A:在Python 3里统一使用Unicode字符串,因此主要的事情就是用decode/encode进行各种编码转换。
在Python 2里主要要注意的是一切可能有非ASCII字符的字符串都使用unicode来处理,当需要的时候再使用encode方法进行编码转化。当打印到终端时,简体中文Windows下为GBK编码(即每个Windows的ANSI编码),Linux下一般可能设置为UTF8编码(可以查看LANG环境变量),一般情况下可以直接打印unicode字符串,但是如果在Windows下打印某些超出GBK范围的字符时,会导致编码转换异常,则可以使用unicode.encode(locale.getdefaultlocale()[1], 'ignore')
来进行编码转化。如果是输入汉字,则使用相应的decode方法来将标准输入的字符串转为unicode字符串。此外,在Python 2里,需要在文件头部加上文件的编码,例如# -*- coding: utf-8 -*-
。