谈谈字符编码的问题

在使用Python进行Web开发的时候,早晚会碰到字符编码的问题,这里以一种比较通俗的方式深入讲解一下字符编码的原理。

为什么需要编码

这个问题比较简单,最直接的回答就是:因为计算机内部只能表示0或者1,再底层一点讲,就是电路的开与关。但是实际应用中,人类对信息的编码是采用文字和符号这种方式。因此,我们需要把0和1映射到人类日常需要的文字和符号,我们也把这种映射关系称作编码方式。

都有哪些编码方式

ASCII,UTF-8,GB2312,这些都是字符编码。对于程序员而言,比较关心的还是ASCII和UTF-8。

先说说ASCII编码,ASCII采用一个字节进行编码的方式,首位是0,也就是说一共可以保存2的7次方,128种不同的符号。这个编码方式对于英文及其常用符号地区是够用了。但是随着发展,其它国家也要给自己的文字和符号进行编码。比如中国,就要给汉字进行编码。然而如果每个国家都给自己的文字和符号进行一次编码。那么最后就会变的无法管理,可能同一个二进制子节,在不同的地方出现不同的编码方式。随着互联网的发展,世界范围内的信息交流和沟通变的更加频繁。于是出现Unicode字符编码,这种编码出现的目的就是为了形成世界范围内通用的编码。这里说下Unicode的编码形式:Unicode最开始采用两个字节进行编码,也就是说最大能容忍的编码个数为2的16次方,65536个。这个数字基本上可以满足全世界的字符编码需求了。当然如果要编码中国的所有汉字,那肯定是不够的,但是编码通用还是足够了,后来Unicode编码长度扩展到3个到4个字节。因此,你只要记住下面这一点

Unicode编码的出现,似乎已经解决编码这个难题,我们所有的字符编码都采用Unicode编码就好了。但是总是有历史包袱的,在这里不得不多说一句,在计算机的世界里,你渐渐会发现有很多疑难杂症,或者难以理解的知识点,有很多都是历史原因把整个问题变的复杂了。在Unicode编码方式出现之前,ASCII编码已经广泛使用一段时间了,于是,接下来要处理一个非常棘手的问题:要如何才能区别当前这个文件是采用了哪种编码呢?估计大家最直接的想法就是在每个文件的开头加一个标识符,表示它的编码方式就可以了。但是很显然我们不能把当前所有的文件回收回来然后打上标识符之后再统一发回去。于是对于Unicode编码方式也不能仅仅是针对它的编码集进行存储了。随着互联网的兴起,交流变的更加频繁,对同于统一编码的呼声越来越高,因此,是时候说说UTF-8了。

UTF-8本身不算是一种编码方式,它仅仅是Unicode编码的一种存储方式(当然你也可以认为它也是一种编码方式,具体后面会谈到)。Unicode对世界范围内做了编码规范,也可以认为是一种编码的表,在这张表里面每个字符都有与自己对应的16个二进制位。但是对于英文字母而言,ASCII的一个字节编码方式已经足够了,如果都统一换成Unicode的编码方式进行存储,那么对存储空间的浪费是无法忍受的。于是UTF-8出现了,它是一种变长的Unicode编码实现方式。它和Unicode编码是一一对应的关系,具体实现如下

对于ASCII编码能够搞定的字符编码,UTF-8也使用一个字节,最高位使用0,其余和ASCII编码一致。注意这里并不是UTF-8自己定义的编码方式,而是Unicode的编码规范,只是真正的Unicode编码会把前面的一个字节全部变为0。这是一个字节的情况,对于n个字节(前面说过UTF-8是一种变长的编码方式),其中第一个字节的前n位都是1,n+1位是0,后面的字节的前两位统一是10,剩下的没有提到的二进制位全部是这个符号的Unicode编码。根据上面的描述,我们可以注意到一个UTF-8的编码不会超过7个字节,但是这个长度也足够它编码世界范围内的字符了。因此UTF-8编码的存储方式往往如下

0xxxxxxx
110xxxxx 10xxxxxx
1110xxxx 10xxxxxx 10xxxxxx
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

其中x可以填充具体字符的Unicode编码,下面举一个具体的例子,例如中文你好,其UTF-8编码如下

>>> s = "你好"
>>> s
'\xe4\xbd\xa0\xe5\xa5\xbd'

转换成二进制表示如下

11100100 10111101 10100000
11100101 10100101 10111101

其UNICODE编码如下

>>> s = u"你好"
>>> s
u'\u4f60\u597d'

转换成二进制表示如下

01001111 01100000
01011001 01111101

对照我上文对UTF-8编码的存储方式是一致的。

乱码的形成

上面讨论了UTF-8的编码实现方式,以及它和ASCII编码是如何和谐相处的。但是这里我们要开始谈谈中文编码,中文编码不少系统默认是采用GB2312编码,GB2312编码具体方式可以阅读相关资料进行详细了解,但是有一点,那就是GB2312编码与UTF-8编码并不是互斥的。也就是说当我们在不知道编码方式的情况下解码一段文本的时候,是无法判断该文本是什么编码的,于是只能靠猜测算法去猜测当前文本是什么编码,但是对于文本量比较小的时候,相同的编码,使用UTF-8和GB2312都可以解开,这个时候就可能会导致乱码的出现。例如“联通”这两个汉字的GB2312编码是 C1AA,CDA8,转换成二进制方式:11000001 10101010,11001101 10101000,这段编码恰巧符合URF-8的编码方式,可以把它当成两个双字节的UTF-8编码,这样就造成了乱码现象的产生。因此,总结来说,乱码就是因为历史原因导致没有统一的编码规范,程序在反解已编码的字符的时候没有按照同一种编码方式工作。

Python中的字符编码问题

在Python的源代码中,我们经常会在文件的开头部分看到这么一行文本

# -*- coding: utf-8 -*-

大多数Python开发人员都知道这是用来指定编码格式的,但是在这里我要告诉你,这行代码仅仅是用来指定当前代码文本使用UTF-8编码,方便Python解释器在读取代码文本的时候正确识别代码文本文件中的字符。但是同时你的编辑器在保存的时候也要使用UTF-8编码格式才可以,否则你在源代码文件标明是UTF-8编码,实际却使用了另外一种编码,这就是欺骗了,这是第一个问题,不过这个问题对于Python开发者来说,一般很少遇到。

Python2.x中对于字符串有两种表示方法,strunicodeunicode更像是一个复杂类型,通常表示一个Unicode对象。而str则是一个基础类型,在Python中它也是基础的不能再基础了,它仅仅表示字符数组([]byte)。而大多数Python中的中文字符编码问题归根结底都是因为这个原因。

'ascii' codec can't decode byte 0xe5 in position 0: ordinal not in range(128)

因为str仅仅是字符数组,因此,即使你把某一段中文使用UTF-8编码的时候,它的存在形式还是字符数组,当你使用len内置方法去求值的时候,你看到的肯定不是中文汉字的个数。这个时候怎么办呢?我们通常会把它转成unicode对象,然后进行操作,这样得到的结果就回是我们预期的。那上面的这段编码错误一般在哪些情况下会报出来呢?这里就要说到Python的隐式转换,当一个str类型变量和一个unicode类型变量进行连接操作的时候,或者对一个str对象使用encode方法的时候我,Python内部都会尝试将当前的str对象转换成unicode对象,然后在进行操作。那当把str对象转换成unicode对象的时候,采用什么编码呢?问题就在于此。它会采用sys.getdefaultencoding()方法返回的编码方式,很不幸的是,往往返回的是都是ascii。而实际上你的str对象里面保存的是UTF-8编码的字符数组,而Python默认却会使用ascii去转换,这个时候就报出上面的错误了。

我们该如何去避免这种编码错误的问题发生呢?解决方法有几个,分别按照优雅方式列举一下。我们在Python进行开发的时候,对字符串统一使用unicode对象来表示,尤其是带中文的字符串,千万不要使用str类型,这样就从根源上避免了Python隐式从str转成unicode的可能性。对于外部传递进来的参数,尤其是网络调用传入的参数,必须先转成unicode类型再进行后续的操作。这种方式比较干净,纯粹。

还有一种方式,就是在Python源码文件的开始处加上下面三行代码

import sys
reload(sys)
sys.setdefaultencoding("utf-8")

这种方式会改变Python默认从str转成unicode采用的编码方式。只要保证我们的中文字符统一采用UTF-8编码方式,这种方式也能很好解决字符编码问题。但是每个文件总是写上这三行代码看上去会比较dirty。还是第一种方式比较彻底,干净一些。

总结

Python的中文编码问题

  1. 源代码文件需要指定文本的编码方式
  2. Python的str类型仅仅是字符数组,同时还有一种unicode类型用于表示Unicode字符串,而在对str进行某些操作的时候,Python会隐式地将str转成unicode对象,而这个转换的编码方式是依靠sys.getdefaultencoding()的返回值
Xiang Chao 15 January 2016
blog comments powered by Disqus
Fork me on GitHub