编码问题小结

0x00 前言

If you’re like most Python programmers, you’ve done it too: you’ve built a nice application, and everything seemed to be going fine. Then one day an accented character appeared out of nowhere, and your program started belching UnicodeErrors.

You kind of knew what to do with those, so you added an encode or a decode where the error was raised, but the UnicodeError happened somewhere else. You went to the new place, and added a decode, maybe an encode. After playing whack-a-mole like this for a while, the problem seemed to be fixed.

Then a few days later, another accent appeared in another place, and you had to play a little bit more whack-a-mole until the problem finally stopped.

翻译 →_→

如果你和其他程序员一样,那你肯定也碰到如下情况了:你写了一个不错的应用,而且程序运行地很不错。然而某一天一个很奇怪的「方言字符」不知道从哪里冒出来了,然后你的程序出现了一堆的UnicodeErrors。

你好像知道这种问题如何解决,于是你在错误的地方添加了encode,decode,但UnicodeError又出现在了其他地方。你在另一个地方又加上了decode,encode,一番「打地鼠」游戏之后,问题似乎被解决了。

又过了几天,另一种”方言字符”又在另外一个地方出现了。然后你又玩起了打地鼠的游戏,直到问题不再出现。

—— 来自Pycon2012,演讲主题:Pragmatic Unicode, or, How do I stop the pain? ,演讲人Ned Batchelder

感觉膝盖上中了无数箭。Python2 的这个让人头疼的编码问题,其实已经足够成为换 python3 的理由了,然而 python2 在很长一段时间内还是会继续发挥余热。不仅仅是python,我们在 C/C++,Java 中都遇到过编码问题,就算不用 Python2 了,如果不懂得原理,迁移到 python3 或其他语言的时候遇到编码问题还是会抓瞎。我不想遇到编码问题时就玩「打地鼠」的游戏,我也不想避过这个问题不谈,10年后再总结这个问题写一篇「完全攻略」,我要依据仅有的一点经验和资料从原理到策略写一篇关于 python2 编码问题的小结。如有谬误,欢迎指正;如有疑问,欢迎讨论。

0x01 背景

要搞明白这个问题,我们首先得知道编码是什么?我们知道数据在计算机中是以二进制存储的,而我们在现实世界使用的是人类可读的字符,比如「1, 2, 3, … A, B, C, 」, 「一,二,三,四」这些,我们需要把字符集的字符与二进制数对应起来,以便文本在计算机中存储、在交互界面显示、在通信网络中传输,这个就是编码。

ASCII 码

ASCII码是美国制定的一套字符编码。1967年,美国发布了 ASCII 的标准,在1986年进行了最后一次更新,下图是 ASCII 编码表:

ASCII

ASCII 定义了128个字符的编码。利用1个字节,也就是8个二进制位,ASCII 编码只规定了128个字符的编码,最高位始终是0。

非 ASCII 码

ASCII 显然是有局限性的,除去32个不能显示的控制字符,只能显示26个拉丁字母,10个阿拉伯数字以及一些英文标点符号,不说非英语系国家的感受,就是英语系国家在使用带注音符号的外来词时都得妥协。后来一些欧洲国家陆续采用新的编码方式,基于 ASCII,但把闲置的最高位利用起来,可以多表示一些字符。这些编码方式中,0-127表示的符号一样,与 ASCII 兼容,128-255这一段是不同的。在亚洲国家,比如中文,日文,不用拉丁字母,用128个比特表示字符肯定是不够的,于是使用多个字节表示一个字符,中国广泛使用的编码是GB 2312,每个汉字及符号以两个字节表示,共收录6763个汉字,但对于一些罕用字和繁体字,GB 2312并不能处理,因此后来相继出现GBK及GB 18030汉字字符集。可以想象,在那个阶段,各种编码方式是蓬勃发展的。

Unicode 编码

编码方式越来越多,世界的交流越来越频繁,没有统一的编码方式给我们造成了很多麻烦,比如发一份电子邮件,如果写信的客户端和收信的客户端没有采用同样的编码就会产生乱码,一个文本文件,在不同平台上编辑也会产生乱码,Unicode(又称万国码、国际码、统一码、单一码)应运而成,这是一种可以容纳世界上所有文字和符号的方案,通过这个字符集,世界上的所有字符都可以拥有一个唯一的二进制表示方法。需要注意的是,Unicode 只规定了编码方式,并没有规定实现方式,怎么理解呢,要知道 Unicode 能表示那么多字符,要占的字节可比 ASCII 编码方式多多了,这当然就不实际了,毕竟在 ASCII 中英文字母只需要一个字节表示,如果 Unicode 的实现方式规定所有字符所占字节相同,那就太浪费硬盘和流量了。以往大家都是把字符集和编码方案混淆在一起的,从 Unicode 开始,人们意识到这两个东西完全是可以分开的,这个时候慢慢出现了 Unicoode 的各种实现方式,其中 utf-8(Unicode/UCS Transformation Format) 是最常用的,它是一种变长的编码方式,一个字符的字节数可能是1至6个字节,现在已经标准化为 RFC 3629。它有两个特点:

  • 对于 ASCII 编码来说,字符用一个字节表示,utf-8 对于在 ASCII 码的范围内的字符,也用一个字节表示,不浪费存储空间,具体表现为,单字节的符号,字节的第一位设为0,后面7位为这个符号的 Unicode 码,这与 ASCII 码是一样的。

  • 字符的范围大于 ASCII 码表范围的,由第一个字节的前几位表示该 Unicode 字符的长度,后面字节的前两位是10,其他部分都是用来区分字符的 Unicode 编码。

Unicode 和 utf-8 之间的转换关系表如下(来自wiki):

码点的位数 码点起值 码点终值 字节序列 Byte 1 Byte 2 Byte 3 Byte 4 Byte 5 Byte 6
7 U+0000 U+007F 1 0xxxxxxx
11 U+0080 U+07FF 2 110xxxxx 10xxxxxx
16 U+0800 U+FFFF 3 1110xxxx 10xxxxxx 10xxxxxx
21 U+10000 U+1FFFFF 4 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
26 U+200000 U+3FFFFFF 5 111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx
31 U+4000000 U+7FFFFFFF 6 1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx

各种编码之间的转换可以通过程序实现,windows 平台下的不少大牛喜欢用记事本写代码,有一部分原因就是因为记事本的编码转换简洁明了。基本所有编辑器和 IDE 的都提供这样的功能,这里不再赘述。

0x02 Python2 中的 str 和 unicode

Python 从1.6版本开始引入了 Unicode 支持。python2 和 python3 关于字符串的存储方式是不同的,这里我们只讨论 python2,首先要清楚的是,在 Python2 中,有两种字符串数据类型,一种是 str(byte string) 类型,存储的是已经编码后的字节序列,如果输出,我们看到的是16进制的字节:

1
2
3
4
5
6
7
In [1]: my_str = 'Hello, 这是中文'

In [2]: my_str
Out[2]: 'Hello, \xe8\xbf\x99\xe6\x98\xaf\xe4\xb8\xad\xe6\x96\x87'

In [3]: type(my_str)
Out[3]: str

另一种是 Unicode (unicode string) 类型,存储的是 code points,str 和 Unicode 有什么关系呢?它们都是 basestring 的子类,但我们必须好好区分,这是解决乱码问题的关键,它们是可以相互转换的,如:

1
2
3
4
5
6
7
8
9
10
In [4]: my_unicode = u'Hello, 这是中文'

In [5]: my_unicode
Out[5]: u'Hello, \u8fd9\u662f\u4e2d\u6587'

In [6]: my_unicode.encode('utf-8')
Out[6]: 'Hello, \xe8\xbf\x99\xe6\x98\xaf\xe4\xb8\xad\xe6\x96\x87'

In [7]: my_str.decode('utf-8')
Out[7]: u'Hello, \u8fd9\u662f\u4e2d\u6587'

用一张图可以说明它们的转换方法:

str-unicode

图1

unicode 转换成 str 类型,用 encode() 方法,用参数指定编码类型,str 转换为 unicode,根据 str 的编码类型指定解码类型,通过 decode() 函数解码。(吐槽一句,有的博客写 python 编码问题,连 encode, decode 的中文翻译都写错,唉)

下面讲解一下常见的错误,通过例子中理解 python2 对于编码的处理方式:

我们把明显超出 ascii 编码的 unicode 用 encode 函数编码为 ascii 码:

1
2
3
4
5
6
7
8
9
10
In [8]: my_unicode
Out[8]: u'Hello, \u8fd9\u662f\u4e2d\u6587'

In [9]: my_unicode.encode('ascii')
---------------------------------------------------------------------------
UnicodeEncodeError Traceback (most recent call last)
<ipython-input-9-26dbddc072f5> in <module>()
----> 1 my_unicode.encode('ascii')

UnicodeEncodeError: 'ascii' codec can't encode characters in position 7-10: ordinal not in range(128)

抛出异常是 UnicodeEncodeError, codec(编码、解码器)表示不能将这段 unicode 编码为 ascii 码,因为有字符超出了 ascii 的范围,这是显然的,根据上一节的背景知识可以知道, ascii 码这么小一张表是不能存放中文字符的。

将 utf-8 编码指定 ascii 编码解码为 unicode:

1
2
3
4
5
6
7
8
9
10
In [10]: my_str
Out[10]: 'Hello, \xe8\xbf\x99\xe6\x98\xaf\xe4\xb8\xad\xe6\x96\x87'

In [11]: my_str.decode('ascii')
---------------------------------------------------------------------------
UnicodeDecodeError Traceback (most recent call last)
<ipython-input-11-9ad191183111> in <module>()
----> 1 my_str.decode('ascii')

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

抛出异常时 UnicodeDecodeError,因为 ascii 只接受128以内的值。实际上 utf-8 只能通过指定 utf-8 类型解码为 unicode,但 ascii 可以通过指定 utf-8 类型解码为 unicode,因为 utf-8 包含了 ascii,如:

1
2
3
4
5
6
7
8
In [17]: my_unicode_for_asc = u'test'

In [18]: my_asc = my_unicode_for_asc.encode('ascii')

In [19]: my_utf8_unicode = my_asc.decode('utf-8')

In [20]: my_utf8_unicode
Out[20]: u'test'

回到抛出异常的问题,如果我们处理抛出异常,可以指定handler,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
In [24]: my_unicode
Out[24]: u'Hello, \u8fd9\u662f\u4e2d\u6587'

In [25]: my_unicode.encode('ascii')
---------------------------------------------------------------------------
UnicodeEncodeError Traceback (most recent call last)
<ipython-input-25-26dbddc072f5> in <module>()
----> 1 my_unicode.encode('ascii')

UnicodeEncodeError: 'ascii' codec can't encode characters in position 7-10: ordinal not in range(128)

In [26]: my_unicode.encode('ascii', 'replace')
Out[26]: 'Hello, ????'

In [27]: my_unicode.encode('ascii', 'ignore')
Out[27]: 'Hello, '

In [28]: my_unicode.encode('ascii', 'xmlcharrefreplace')
Out[28]: 'Hello, &#36825;&#26159;&#20013;&#25991;'

In [29]: my_unicode.encode('ascii', 'backslashreplace')
Out[29]: 'Hello, \\u8fd9\\u662f\\u4e2d\\u6587'

你可以通过几种方式处理编码时的异常问题,这部分可以查看 python2 的文档。同理,你也可以在解码的时候指定异常处理方式,是忽略不能解码的部分还是替换成其他字符,可以根据实际选择,看文档吧,这里不再赘述。

0x03 解决方案

实际上,清楚上面的图1之后,应该就清楚遇到这类情况应该怎么处理了,比如我要抓取一个网页,从 html 文件的代码中找到:

1
<meta charset="UTF-8">

嗯,这个 html 页面的代码是 utf-8 编码的,而我要写入数据的文件格式是 gbk 编码,正确的处理方法是:

1
2
unicode_str = html_content.decode('utf-8')
file_content = unicode_str.encode('gbk')

同样的,当我们需要从 ascii 编码的文件中读取数据,输出到默认编码为 utf-8 的终端时:

1
2
unicode_str = file_content.decode('ascii')
print_content = unicode_str.encode('utf-8')

最后总结一下吧,处理 python2 的编码问题,首先你需要清楚的是数据来源和数据去向的编码,再以 unicode 作为中介,搞清楚 decode 和 encode 的作用,就不用再玩「打地鼠」的游戏了。

最后给出一个具体的实践方案,实际上与 Pragmatic Unicode, or, How do I stop the pain? 的思路是一样的,按照视屏中的提法,最重要的是在程序中构造一个 unicode 三明治。

具体步骤:首先在 python 文件头部声明选用字符集:

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

作用是告诉 Python 解释器,按照 utf-8 编码读取源代码,使得该 python 文件支持中文字符串、中文注释(当然也就可以支持其他语言),用某些编辑器中保存该文件时会根据这个头部的编码确定文件的编码。

其次,在程序入口处设置字符串自动转换时的默认编码:

1
2
3
import sys
reload(sys)
sys.setdefaultencoding('utf-8')

这个怎么理解呢?这个要从一个例子说起,在 python2 中,我们要将一个 unicode 和一个 str 串合并起来,python 是会自动帮你将 str 解码的,但默认的编码是 ascii 编码,就像下面的情况:

1
2
3
4
5
6
7
In [11]: test = u'test' + '中文'
---------------------------------------------------------------------------
UnicodeDecodeError Traceback (most recent call last)
<ipython-input-11-518e0d33fd35> in <module>()
----> 1 test = u'test' + '中文'

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

test = u'test' + '中文' 这个连接部分就等同于:

1
2
3
4
5
6
7
In [13]: test = u'test' + '中文'.decode('ascii')
---------------------------------------------------------------------------
UnicodeDecodeError Traceback (most recent call last)
<ipython-input-13-bda4e952bf48> in <module>()
----> 1 test = u'test' + '中文'.decode('ascii')

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

我们通过上面的三行代码改变了 python 的「智能化」操作,设置utf-8为默认编码后,问题就不会再出现了(关于为什么在 sys.setdefaultencoding('UTF-8') 之前要 reload(sys),可以参考这篇博客,查了一下,在早期的版本中,是没有del sys.setdefaultencoding这一行的,所以不用 reload(sys),但如果用比较新的 python2 版本就需要加上这行):

1
2
3
4
5
6
7
8
9
10
11
In [14]: import sys

In [15]: reload(sys)
<module 'sys' (built-in)>

In [16]: sys.setdefaultencoding('utf-8')

In [17]: test = u'test' + '中文'

In [18]: print test
test中文

加上这三行代码是为了之后操作的方便,理解了原理,不加也是可以的,只是遇到字符串连接时稍加注意就是了。

最后,在写代码的过程中,遵守一个原则,构造 unicode 三明治状态,在程序中处理的所有字符串都以 unicode 存储,如果有外部输入,读文件也好,从数据库中取数据也好,根据输入数据编码 decode 为 unicode 类型,比如从命令行输入,就可以用sys.stdin.encoding获得输入时的编码,输出时,根据输出的内容的编码,encode 为对应编码即可,始终保持程序中处理的的字符串数据为 unicode,这样基本上就不会有问题了。

看过一些源代码,我发现其实这种方式正在慢慢成为默认的规范,好几个开源项目的源代码中都采用这个做法,比如 BeautifulSoup库用的就是这种方式:https://www.crummy.com/software/BeautifulSoup/bs4/doc/#making-the-soup

我想这个问题可以告一段落了。

参考:

[0]. https://zh.wikipedia.org/wiki/ASCII
[1]. https://zh.wikipedia.org/wiki/UTF-8
[2]. https://en.wikipedia.org/wiki/Unicode
[3]. https://www.youtube.com/watch?v=sgHbC6udIqc&feature=youtu.be
[4]. https://gist.github.com/x7hub/178c87f323fbad57ff91
[5]. http://blog.csdn.net/hherima/article/details/8655200
[6]. http://www.ruanyifeng.com/blog/2007/10/ascii_unicode_and_utf-8.html
[7]. https://www.zhihu.com/question/23374078
[8]. https://www.zhihu.com/question/40870506
[9]. http://www.liaoxuefeng.com/wiki/001374738125095c955c1e6d8bb493182103fac9270762a000/001386819196283586a37629844456ca7e5a7faa9b94ee8000
[10]. http://www.cnblogs.com/huxi/articles/1897271.html
[11]. http://www.pconline.com.cn/pcedu/empolder/gj/other/0505/616631.html
[12]. https://segmentfault.com/q/1010000002438151