Python2 编码

210 阅读14分钟

前言

在使用Python2的开发中,经常遇到如下情况报错,今天就探究一下:

res = {'uuid'u'4cfadbb3-fed8-11ea-b590-006482bbd931', 'contact': '小刘'}
msg= 'uuid: {0}, contact: {1}'.format(res['uuid'], res['contact'])
UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-2: ordinal not in range(128)

最近看到一篇关于字符串的及编码的文章,所以总结一下:

文中提到: Everything you thought you knew about strings is wrong. 因为编程学习首先要有其思想,所以要知其然 要更知其所以然

为什么需要字符编码


当我们谈到字符串(string或text)时,你可能会想到“计算机屏幕上的那些字符(characters)与符号(symbols)”,你正在阅读的文章,无非也是由一串字符组成的。但是你也许会发现,你无法给“字符串”一明确定义,但是我们就是知道,就像给你一个苹果,你能说出其名字,但是不能给出准确定义一样。

我们知道,计算机并不能直接处理操作字符与符号,它只认识 0、1 这两个数字,所以如果想让计算机显示各种各样的字符与符号,就必须定义它们与数字的一一映射关系,也就是我们所熟知的字符编码(character encoding)。 你可简单的认为,字符编码为计算机屏幕上显示的字符与这些字符保存在内存或磁盘中的形式提供了一种映射关系。字符编码纷繁复杂,有些专门为特定语言优化,像针对简体中文的编码就有 EUC-CN,HZ;针对日文的EUC-JP,针对英文的 ASCII;另一些专门用于多语言环境,像后面要讲到的 UTF-8。

我们可以吧字符编码看做一种解密秘钥(decryption key),当我们收到一段字节流时,无论来自文件还是网络,入股我们知道他是文本,那么我们就需要知道采用何种字符编码来解码,否则,我们得到的只是一个队无意义的符号,像 ������

单字节编码

英文中的符号比较少,用七个二进制位就足以表示,现在最常见也是最流行的莫过于 ASCII 编码,该编码使用 0 到 127 之间的数字来存储字符(65表示“A”,97表示“a”)。

字符集 vs 字符编码 在介绍完 ASCII 之后,需要强调一个很重要但被大多数人都忽略的一个概念问题。

我们平时说的 ASCII 其实有两个含义,一个是 ASCII 字符集,另一个是 ASCII 编码。

ASCII 字符集只是定义了字符与字符码(character code,也称 code point 代码点)的对应关系。也就是说这一层面只是规定了字符A用 65 表示,至于这个 65 在内存或硬盘中怎么表示,它不管,那是 ASCII 编码做的事。

ASCII 编码规定了用 7 个二进制位来保存 ASCII 字符码,即定义了字符集的存储形式。

说到这里你也许会问,那既然用 7 个二进制位就能够表示所有 ASCII 字符码了,为什么现在一个字节是 8 位,而不是 7 位呢,这不是浪费吗?

其实这是早期设计者有意而为之:

7 位表示 ASCII 字符码,剩下 1 位为 Parity bit,也称为校验位,用以检查数据的正确性。

关于数据校验,我这里不打算展开讲,感兴趣的可以参考 Error detection and correction。

为了让大家更清楚的明白这两者以及相关概念的关系,我画了图,便于大家理解。

Character 字符。即我们看到的单个符号,像“A”、“啊”等
Code point 代码点。一个无符号数字,通常用16进制表示。代码点与字符的一一对应关系称为字符集(Character Set),这种对应关系肯定不止一种,也就导致了不同字符集的出现,像 ASCII、ISO-8859-1、GB2312、GBK、Unicode 等。
Bytes 二进制字节。其含义为代码点在内存或磁盘中的表示形式。代码点与二进制字节的一一对应关系称为编码(Encoding),当然这种对应关系也不是唯一的,所以编码也有很多种,像 ASCII、ISO-8859-1、ENC-CN、GBK、UTF-8等。

多字节编码

多字节编码主要用于我们亚洲国家,像中文(Chinese),日文(Japanese),韩文(Korean)(业界一般称为 CJK)等象形(表意)文字(ideograph-based language),字符数量比较多,1 个字节是放不下的,所以需要更多的字节来进行字符的编码。

ISO/IEC 2022 标准为多字节编码制定了一套标准,主要有下面两个方面:

一个编码系统里面可以表示多种字符集 一个编码系统既可以在 7 位编码系统中表示所有字符集,也可以在 8 位编码系统表示所有字符集 为了能够表示多种字符集 ISO/IEC 2022 引入了escape sequences,也就是我们中文里面说的“转义字符”的意思;为了能够兼容之前的 7 位编码系统,像 ASCII,实现 ISO/IEC 2022 标准的编码系统一般都是变长编码。

GB2312 GB2312 是我国国家标准总局在1980年发布一套遵循 ISO/IEC 2022 标准的字符集,在 GB2312 中,字符码一般称为区位码,由于该字符集需要兼容 ASCII 字符集,所以它只能一个字节中的 7 位,剩下 1 位用于区分,比如可以通过最高位为 1 表示 GB2312 字符集,为 0 表示 ASCII 字符集。

GB2312 使用两个字节来表示字符码,最多可以表示 94 * 94 个字符,但是 GB2312 并没有全部使用,留了一部分方便后面扩展用。

实现 GB2312 字符集的编码主要是 EUC-CN,该编码与 ASCII 编码兼容。

我们平时说的 GB2312 编码其实就是指的 EUC-CN 编码,这一点需要明白。

GBK GBK 字符集是对 GB2312 字符集的扩展,GBK 并没有一个官方标准,现在使用最广的标准是微软在 Windows 95 中实现的版本——CP936 编码。

GBK 也使用两个字节来表示字符码,94 * 94 的区位码分布可以参考下面这个图,截自 Wikipedia

Unicode

Unicode 的全称是 universal character encoding,中文一般翻译为“统一码、万国码、单一码” 在 Unicode 中字符码称为 code point,用 4 个字节来表示,这么做主要是为了涵盖世界上所有的字符。写法一般为U+XXXX,XXXX 为用 16 进制表示的数字。比如,U+0041表示A。

Unicode 的存储形式 Unicode 的存储形式一般称为UTF-*编码,其中 UTF 全称为 Unicode Transformation Format,常见的有:

UTF-32 UTF-32 编码是 Unicode 最直接的存储方式,用 4 个字节来分别表示 code point 中的 4 个字节,也是 UTF-编码家族中唯一的一种定长编码(fixed-length encoding)。UTF-32 的好处是能够在O(1)时间内找到第 N 个字符,因为第 N 个字符的编码的起点是 N4 个字节,当然,劣势更明显,四个字节表示一个字符,别说以英文为母语的人不干,我们中国人也不干了。

UTF-16 UTF-16 最少可以采用 2 个字节表示 code point,需要注意的是,UTF-16 是一种变长编码(variable-length encoding),只不过对于 65535 之内的 code point,采用 2 个字节表示而已。如果想要表示 65535 之上的字符,需要一些 hack 的手段,具体可以参考wiki UTF-16#U.2B10000_to_U.2B10FFFF。很明显,UTF-16 比 UTF-32 节约一半的存储空间,如果用不到 65535 之上的字符的话,也能够在O(1)时间内找到第 N 个字符。

UTF-16 与 UTF-32 还有一个不明显的缺点。我们知道不同的计算机存储字节的顺序是不一样的,这也就意味着U+4E2D 在 UTF-16 可以保存为4E 2D,也可以保存成2D 4E,这取决于计算机是采用大端模式还是小端模式,UTF-32 的情况也类似。为了解决这个问题,引入了 BOM (Byte Order Mark),它是一特殊的不可见字符,位于文件的起始位置,标示该文件的字节序。对于 UTF-16 来说,BOM 为U+FEFF(FF 比 FE 大 1),如果 UTF-16 编码的文件以FF FE开始,那么就意味着其字节序为小端模式,如果以FE FF开始,那么就是大端模式。 其他 UTF-* 编码的 BOM 可以参考 Representations of byte order marks by encoding。

UTF-8 UTF-16 对于以英文为母语的人来说,还是有些浪费了,这时聪明的人们(准确说是Ken Thompson与Rob Pike)又发明了另一个编码——UTF-8。在 UTF-8 中,ASCII 字符采用单字节。其实,UTF-8 前 128 个字符与 ASCII 字符编码方式一致;扩展的拉丁字符像ñ、ö等采用2个字节存储;中文字符采用 3 个字符存储,使用频率极少字符采用 4 个字节存储。由此可见,UTF-8 也是一种变长编码(variable-length encoding)。

UTF-8 的编码规则很简单,只有二条:

1. 对于单字节的符号,字节的第一位设为0,后面7位为这个符号的 code point。因此对于英语字母,UTF-8编码和ASCII码是相同的。
2. 对于n字节的符号,第一个字节的前n位都设为1,第n+1位设为0,后面字节的前两位一律设为10。剩下的没有提及的二进制位,全部为这个符号的 code point。

通过上面这两个规则,UTF-8 就不存在字节顺序在大小端不同的情况,所以用 UTF-8 编码的文件在任何计算机中保存的字节流都是一致的,这是其很重要一优势;UTF-8 的另一大优势在于对 ASCII 字符超节省空间,存储扩展拉丁字符与 UTF-16 的情况一样,存储汉字字符比 UTF-32 更优。

UTF-8 的一劣势是查找第 N 个字符时需要O(N) 的时间,也就是说,字符串越长,就需要更长的时间来查找其中的每个字符。其次是在对字节流解码、字符编码时,需要遵循上面两条规则,比 UTF-16、UTF-32 略麻烦。

随着互联网的兴起,UTF-8 是逐渐成为使用范围最广的编码方案。 liujiacai.net/blog/2016/0…

Python2中的字符类型


python 2中有两种字符类型: str 与 Unicode, 其区别是:

str is text representation in bytes, unicode is text representation in charactters

字符字面量是str 类型,也就是说foo='您好'这一句话表示的是把 您好 所对应的二进制字节赋值给foo,在Python2中的str类型相当于其他语言的byte类型

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

unicode对象保存的是字符的code point。在 Python 2 如果想表示 unicode 类型,有下面三种方式:

>>> u"你好"
u'\u4f60\u597d'
>>> "你好".decode("utf8")
u'\u4f60\u597d'
>>> unicode("你好", "utf8")
u'\u4f60\u597d'

Python 2 中的默认编码


sys.getdefaultencoding()可以得到当前 Python 环境的默认编码,Python 2 中为ascii。str与unicode两种字符类型中转化时,如果没有明确指定编码方式,就会用这个默认编码。

Python 2 中编码问题出现根源


了解了 Python 2 中的两种字符类型以及默认编码,现在就可以分析与编码相关的问题出现的原因了。

在 Python 2 的世界中,很多 API 对这两种字符类型的使用比较混乱,有的可以混用这两种,有的只能使用其中之一,如果在调用 API 时传入了错误的字符类型,Python 2 会自动去转为正确的字符类型,问题就出现在自动转化时用的编码默认是ascii,所以经常会出现UnicodeDecodeError或UnicodeEncodeError错误了。

随着 unicode 的普及,Python 2 中越来越多的 API 使用 unicode 类型的字符串作为参数与返回值,我们在设计 API 时,也尽可能要使用unicode类型。那是不是说,把程序里面的所有字符串都用unicode类型表示,就不会出错了呢?也不尽然,一般有如下准则:

在进行文本处理(如查找一个字符串中字符的个数,分割字符串等)时,使用unicode类型 在进行I/O处理(如,读写磁盘上的文件,打印一个字符串,网络通信等)时,使用str类型 想想也很好理解,因为 Python 2 中的str类型相当于其他语言的byte类型,在进行I/O时操作的是一个个的字节。

字符串拼接、比较 Python 中字符串在进行拼接与比较时,如果一个是str类型,另一个是unicode类型,那么会把str隐式转为unicode类型

>>> "%s, %s" % (u"你好", "中国")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'ascii' codec can't decode byte 0xe4 in position 0: ordinal not in range(128)
>>> u"你好" > "中国"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'ascii' codec can't decode byte 0xe4 in position 0: ordinal not in range(128)

解决方法也很简单,就像上面说的,只要不涉及到I/O操作,一律用unicode类型。

>>> u"%s, %s" % (u"你好", u"中国")
u'\u4f60\u597d, \u4e2d\u56fd'
>>> u"你好" > u"中国"
True

读写文件

内置函数 [open(name, mode[, buffering]]可以返回一个文件类型的对象,这里返回的文件对象操作的是str类型的字符,我们可以手动将读到的内容转为unicode类型,但是这里有个问题, 对于多字节编码来说,一个 unicode 字符可能被数目不同的字节表示,如果我们读取了任意固定大小(比如1K,或4K)的数据块,这个数据快的最后几个字节很可能是某个 unicode 字符的前几个字节,我们需要去处理这种异常,一个比较笨的解决方式是把所有数据读取到内存中,然后再去转码,显然这不适合大数据的情况。一个比较好的方法是使用codecs模块的open(filename, mode='rb', encoding=None, errors='strict', buffering=1)方法,这个方法返回的文件对象操作的是unicode类型的字符,

# cat /tmp/debug.log
你好

>>> with open('/tmp/debug.log') as f:
>>>     s = f.read(1)    # 读一个字节
>>>     print type(s)    # str
>>>     print s          # 无意义的一个符号
>>>
>>> import codecs
>>>
>>> with codecs.open('/tmp/debug.log', encoding='utf-8') as f:
>>>     s = f.read(1)    # 读一个字符
>>>     print type(s)    # unicode
>>>     print s          # 你


如果我们用内置的open进行写文件,必须将unicode字符转为str字符,否则会报错。

这个错误很典型,就是因为用默认的ascii去编码你好导致的,显然你好不在ascii字符集内,正确的方式:

>>> with open('/tmp/debug.log', 'w') as f:
>>>     f.write(u'你好')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)


>>> with open('/tmp/debug.log', 'w') as f:
>>>     f.write(u'你好'.encode('utf-8'))

$ cat /tmp/debug.log
你好

print

首先需要注意的是 print 在 Python 2 中是一个表达式(和if、return同一级别),而不是一个函数。 print有两种语法形式:

$ cat encode.py
# coding: utf-8
import sys
print sys.stdout.encoding
print u"你好"

$ python encode.py
UTF-8
你好

$ LC_ALL=C python encode.py
US-ASCII
Traceback (most recent call last):
  File "encode.py", line 21, in <module>
    print u"你好"
UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)

当 print 通过重定向,不是打印到标准输出sys.stdout时,由于它不知道目标文件的locale,所以它又会用默认的ascii进行编码了。

$ python encode.py > abc
Traceback (most recent call last):
  File "encode.py", line 21, in <module>
    print u"你好"
UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)

$ cat abc
None

$ PYTHONIOENCODING=UTF-8 python encode.py > abc
$ cat abc
UTF-8
你好

可以看到,在不指定PYTHONIOENCODING时,sys.stdout.encoding输出None了,并且执行print u"你好"时报错了。

为了解决打印unicode字符的问题,我们可以通过codecs.StreamWriter来包装一次sys.stdout对象。例如:

总结

通过上面的分析,想象大家对 Python 2 中为什么会出现那么多的编码错误有所了解,根本原因就在于 Python 设计早期混淆了byte类型与str类型,好歹在 Python 3 解决了这个设计错误。 在另一方面,这里的编码问题对我们理解计算机的运行原理很有帮助,也反映出copy & paste的危害,希望大家看了我这篇文章之后,严禁reload(sys)这种做法,推荐使用from future import unicode_literals来将所有字符字面量表示为 unicode。

原文