对Python字符编码问题的分析

很久之前就听过“人生苦短,我用Python!”这句豪言,那时便对Python这门语言有了一种特别的感情,再加上近几年越来越多的国外名校使用Python作为CS的入门语言,同时基于对爬虫的兴趣,我便入了Python这个坑,初入Python便被它那“优雅、明确、简单”的设计哲学征服了,看Python的代码就像看英文一样,简直是一种享受,但同时在这学习的过程中也遇到了一些问题,第一个遇到的问题就是爬取网页源代码时中文乱码的问题,Google之后发现这不是一个简单的问题,背后涉及到了很多自己现在还不懂的知识,现在就开始认真的梳理一遍吧!

1、乱码

我们首先从问题的开始慢慢深入,关于乱码,Wikipedia上给出的解释是:乱码是因为“所使用的字符的源码在本地计算机上使用了错误的显示字库”,或在本地计算机的字库中找不到相应于源码所指代的字符所致。简单的理解就好像加密和解密一样,当把一个采用某个密码加密的源文件解密时,若采用不同于其加密时的密码解密,则显然不能解出正确的源文件。因此想要解决乱码这个问题,首先我们要知道源码的编码方式,然后按照源码的编码方式进行解码,即双方使用同一编码系统。那么现在问题就到了编码这一块了。​

2、编码

关于编码,分为很多种,这里我们详细了解字符编码。首先要了解相关的概念,什么是字符编码?字符编码(Character encoding)是把字符集中的字符,编码为指定集合中某一对象(例如:比特模式、自然数序列、八位组或者电脉冲),以便文本在计算机中存储和通过通信网络的传递。简单来说就好像数学中的映射,每个象都有所对应的原象。那么该怎样编码呢,即编码模型是什么呢?就现在来说,存在两种编码模型:简单字符集和现代编码模型;

简单字符集是指一个字符集定义了这个字符集里包含什么字符,同时把每个字符如何对应成计算机里的比特也进行了定义。例如ASCII,在ASCII里直接定义了A -> 01000001

而现代编码模型由Unicode和UCS所构成,它们将字符编码的概念分为:有哪些字符、它们的编号、这些编号如何编码成一系列的码元以及最后这些码元如何组成八位字节流,在UTR(Unicode Technical Report)中,现代编码模型分为5个层次:分别为

(1)、抽象字符表(Abstract character repertoire)是一个系统支持的所有抽象字符的集合。字符表可以是封闭的,即除非创建一个新的标准(ASCII就是这样的例子),否则不允许添加新的符号;字符表也可以是开放的,即允许添加新的符号(Unicode就是这方面的例子)。

(2)、编码字符集(CCS:Coded Character Set)将字符集C中每个字符映射到1个坐标(整数值对:x,y)或者表示为1个非负整数N。简单来说就是给字符表里的抽象字符编上一个数字,例如,在一个给定的字符表中,表示大写拉丁字母“A”的字符被赋予整数65、字符“B”是66,如此继续下去。Unicode就属于这一层次。

(3)、字符编码表(CEF:Character Encoding Form)也称为“storage format”,是将编码字符集的非负整数值(即抽象的码位)转换成有限比特长度的整型值(即码元)的序列。简单来说就是将CCS里字符对应的整数转换成有限长度的比特值,便于以后计算机使用一定长度的二进制形式表示该整数,而这种转换的关系就是CEF。这对于定长编码来说是个到自身的映射,但对于变长编码来说,该映射比较复杂,把一些码位映射到一个码元,把另外一些码位映射到由多个码元组成的序列。UTF-8、UTF-16就属于这一层次。

(4)、字符编码方案(CES:Character Encoding Scheme)也称作“serialization format”,简单来说就是解决对于CEF得到的比特值具体如何在计算机中进行存储、传输的问题,因为存在大端和小端的情况。

(5)、传输编码语法(transfer encoding syntax)是用于处理上一层次的字符编码方案提供的字节序列。一般其功能包括两种:一是把字节序列的值映射到一套更受限制的值域内,以满足传输环境的限制;另一是压缩字节序列的值。

平常我们所说的编码到CEF就结束了,并没有涉及CES。那么现代编码模型分为这么多的层次,好像看起来比简单字符集更复杂了呢,它的优点究竟在哪里呢,这还得从编码的发展历史开始说起。

在计算机技术发展的早期,ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)逐渐成为标准,它至今为止共定义了128个字符,其中33个字符无法显示,且这33个字符多数都已是陈废的控制字符。控制字符的用途主要是用来操控已经处理过的文字。剩下的95个可显示的字符,包含用键盘敲下空白键所产生的空白字符也算1个可显示字符(显示为空白)。ASCII将字母、数字和其它符号编号,并用7比特的二进制来表示这个整数。通常会额外使用一个扩充的比特,以便于以1个字节的方式存储。但是,ASCII的局限在于只能显示26个基本拉丁字母、阿拉伯数目字和英式标点符号,因此只能用于显示现代美国英语(而且在处理英语当中的外来词如naïve、café、élite等等时,所有重音符号都不得不去掉,即使这样做会违反拼写规则)。而其扩展版EASCII虽然解决了部分西欧语言的显示问题,但对更多其他语言依然无能为力。随着计算机的普及,各种语言的人都用上了计算机,至此编码就进入了万码奔腾的时代了。

人们知道计算机的一个字节是8位,可以表示256个字符。ASCII却只使用了7位,所以人们决定把剩余的一位也利用起来。这时问题出现了,人们对于已经规定好的128个字符是没有异议的,但是不同语系的人对于其他字符的需求是不一样的,所以对于剩下的128个字符的扩展会千奇百怪。而且更加混乱的是,在亚洲的语言系统中有更多的字符,一个字节无论如何也满足不了需求了。例如仅汉字就有10万多个,一个字节的256表示方式怎么能够满足呢。于是就又产生了各种多字节的表示一个字符方法(GBK就是其中一种),这就使整个局面更加的混乱不堪。每个语系都有自己特定的编码页的状况,使得不同的语言出现在同一台计算机上,不同语系的人在网络上进行交流都成了痴人说梦。这时Unicode出现了。

Unicode(又称万国码、国际码、统一码、单一码)对世界上大部分的文字系统进行了整理、编码,使得电脑可以用更为简单的方式来呈现和处理文字。Unicode伴随着UCS的标准而发展,同时也以书本的形式对外发表。Unicode至今仍在不断增修,每个新版本都加入更多新的字符。Unicode发展由非营利机构统一码联盟负责,该机构致力于让Unicode方案取代既有的字符编码方案。

上面这一段对Unicode的解释说明中又一次涉及到了UCS,那么接下来我们了解一下UCS,UCS(Universal Character Set,通用字符集)是由ISO制定的ISO 10646(或称ISO/IEC 10646)标准所定义的标准字符集。

说了这么多,那么Unicode和UCS之间的关系是什么呢?简单来说,Unicode和UCS是两个不同的组织制定的编码字符集,Unicode由统一码联盟制定,统一码联盟(The Unicode Consortium)是由Xerox、Apple等软件制造商于1988年组成的一个非营利机构,后来有来自多个国家政府和各大软件商的代表参与;UCS则由ISO制定,相信大家多多少少都见过ISO这个词,ISO(International Organization for Standardization,国际标准化组织)是一个制作全世界工商业国际标准的各国国家标准机构代表的国际标准建立机构。历史上这两个独立的组织都尝试创立单一的编码字符集,然而到1991年前后,两个项目的参与者都认识到,世界不需要两个不兼容的字符集。于是,它们开始合并双方的工作成果,并为创立一个单一编码表而协同工作。至今为止两个项目仍都独立存在,并独立地公布各自的标准,但统一码联盟和ISO都同意保持两者制定的编码字符集相互兼容,并紧密地共同调整任何未来的扩展。

了解了前面那些CCS层次的概念后,接下来我们终于进入CEF层次了。在前面的现代编码模型的五个层次里我们已经知道了CEF就是把CCS里那些纯数学数字转换成有限长度的比特值的这种关系,那么该怎么转换呢?最直观的方法当然是直接把编码字符集中某个字符码点所对应的数字转换成相应的二进制表示,例如“严”在Unicode中对应的数字是“0x4E25”,转换成二进制为“1001110 00100101”,也就是说“严”这个字需要两个字节进行存储,按照这种方法大部分汉字都可以用两个字节来存储,但对于其他语系中的某个字符按照这种方法也许就需要更多的字节来存储。那么问题来了,究竟该用多少个字节来表示一个字符呢?如果规定用两个字节来表示,那么一些字符就无法被表示出来;如果规定用更多的字节来表示一个字符,那么那些原本用较少字节就可以表示一个字符的语言,例如英文字母只用一个字节表示就够了,若用两个或四个字节来表示,那么每个英文字母前都必然有二到四个字节是0,这对于存储来说是极大的浪费,文本文件的大小会因此大出二三倍,这是无法接受的。

那么这时我们换个角度思考问题,可不可以用一个变长的字节来存储一个字符呢?如果用变长的字节来表示一个字符,那么就必须知道是几个字节表示了一个字符,要不然计算机可没那么聪明。UTF(Unicode Transformation Format,Unicode转换格式)正是为了解决这个问题而诞生的。接下来我们介绍UTF,首先是最常用的UTF-8,它使用1~4个字节表示一个符号,根据不同的二进制流而改变字节长度。UTF-8的规则很简单,只有二条:(1)、对于单字节的符号,字节的第一位设为0,后面7位为这个符号的Unicode码。因此对于同一个英语字母在UTF-8下和在ASCII码下所对应的二进制位是相同的;(2)、对于n字节的符号(n>1),第一个字节的前n位都设为1,第n+1位设为0,后面字节的前两位一律设为10。剩下的二进制位用来从后往前放置这个符号的二进制流。下表总结了编码规则,x代表用来放置二进制码的位置。

Unicode码点范围(十六进制) UTF-8编码方式(二进制)
0000 0000 – 0000 007F 0xxxxxxx
0000 0080 – 0000 07FF 110xxxxx 10xxxxxx
0000 0800 – 0000 FFFF 1110xxxx 10xxxxxx 10xxxxxx
0001 0000 – 0010 FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

还是用刚才的“严”字来做例子解释一下,“严”在Unicode中对应的数字是“0x4E25(1001110 00100101)”,根据上表,可以发现“0x4E25”处在第三行的范围内(0000 0800 – 0000 FFFF),因此“严”的UTF-8编码需要3个字节来表示,即“1110xxxx 10xxxxxx 10xxxxxx”格式,那么按照上面的规则从“严”的最后一个二进制位开始,依次从后向前填入格式中的x,多出的位补0,这样就得到了“严”的UTF-8编码是“11100100 10111000 10100101”。以上就是UTF-8编码方式的基本内容,除了UTF-8外,还有UTF-16、UTF-32、UCS-2、UCS-4等。

3、Python中的编码问题

前面铺垫了那么多,终于进入今天的主题了,究竟在Python中编码问题是怎么一回事呢?对了,这里要说明一下,因为我学的是Python 2.x版本的,所以讨论的编码问题也是此版本的,听说在Python 3.x版本中编码问题得到了有效的解决,以后有时间确实要学习一下Python 3.x,毕竟那才是Python的未来。闲话不多说,我们先从一个简单的Python程序开始:

上面这个程序运行起来会报这样的语法错误:“SyntaxError: Non-ASCII character '\xe7' in file ……,but no encoding declared;……”,翻译过来其实就是说在这个Python文件中有非ASCII字符16进制的e7,但是却没有进行编码声明,所以程序报错了,这是因为Python是默认以ASCII编码的,然而我们前面也说过ASCII是无法编码中文的,因此程序中有中文时就要进行编码声明,我们可以在代码的前面加上“#coding:utf-8”这段代码,其作用是进行编码声明以UTF-8编码,这样程序就可以正常运行了,有时你也许会见到有这样的写法:“#-*- coding: UTF-8 -*-”,这种写法也是可以的,实际上Python只检查#、coding:和编码字符串,其他的字符都是为了美观而加上的,另外,Python中可用的字符编码有很多,并且还有许多别名,还不区分大小写,比如UTF-8也可以写成u8。

在Python中字符串有两种类型:str与unicode,严格上来说,str其实是字节串,由unicode经过编码后的字节组成;unicode才是真正意义上的字符串,由字符组成。下面这段代码则是两种类型的定义方式:

值得注意的是,在Python中str就相当于CEF,而unicode就相当于CCS,因此str与unicode之间存在这样的转换关系:

对str只能解码,对unicode只能编码;因此上面那段代码也可以转换成下面这样:

事实上,我们要在不用的编码之间进行转换,也是以unicode作为中间编码,先解码后编码的,例如:s.decode('a').encode('b')表示将s先以a编码方式解码为unicode编码方式再以b编码方式将其转换为新的编码类型。那么如何判断一个字符串是否为unicode/str呢?

4、建议

下面是针对乱码问题的一些建议,

(1)、程序开始时统一使用字符编码声明;

(2)、字符串统一使用unicode类型,抛弃str类型。



发表评论

电子邮件地址不会被公开。 必填项已用*标注