数字营销 · Web开发 · 基础设施

Python UnicodeEncodeError问题的分析和思考

分析 Python 中 UnicodeEncodeError 的产生原因,从文件编码、字符串类型和 encode/decode 入手,了解 Unicode 字符在输出到不同编码环境时发生错误的原理。

最近在利用 Python 获取网络数据的过程中出现了一个问题,系统会时不时抛出 UnicodeEncodeError 错误并停止运行,严重影响了其它功能的运行。程序类似下面这样:

#!/usr/bin/env python
# -*- coding:utf-8 -*-

import urllib2

print urllib2.urlopen(" http://www.example.com/examplepath").read()

报错信息如下:

UnicodeEncodeError: 'gbk' codec can't encode character
u'\u2022' in position XXXX: illegal multibyte sequence

经过检查,发现是因为获取的远程文件中含有“·”这个字符,官方解释是“着重号(BULLET)”,其 Unicode 编码是 U+2022。也就是说,因为获取到的远程内容中含有这个字符,而当前输出环境使用的 GBK 编码无法表示它,所以程序出现了 UnicodeEncodeError

在 Python Wiki 页面中,找到了一篇关于 PrintFails 的说明:

如果用 print 函数向控制台输出一个 Unicode 字符串,而当前控制台编码无法表示这个字符,就会出现类似错误。

这段说明的核心意思是:Python 控制台输出时,会经过 sys.stdout 以及相应的文本输出层,最终根据目标编码输出到控制台。如果目标编码无法表示当前字符,就会发生 UnicodeEncodeError

要理解这个问题,我们先分析一下文件编码和变量编码。

文件编码与变量编码

Python 内部是如何处理各种不同编码的呢?当程序文件的编码和字符串使用的编码不一样时,Python 会进行怎样的处理?

世界上存在各种各样的字符编码,比如 GB2312、GBK、ASCII,以及我们现在经常使用的 UTF-8。很多编码都兼容 ASCII,这也是不同编码可以共同使用英文字符的原因。

那么 Python 如何对不同编码文件中的字符串进行处理呢?例如:

#!/usr/bin/env python
# -*- coding:gbk -*-

stra = "你"
strb = u"你"

print stra
print strb

print stra.encode("hex")
print hex(ord(strb))

print strb.encode("utf-8").encode("hex")

print isinstance(stra, str)
print isinstance(strb, unicode)

这个 Python 文件以 GBK 的方式保存,并在文件头部声明了编码方式为 GBK。

第三行和第四行分别声明了两个字符串变量,不同的是 strb 使用了 Unicode 字符串。

这里需要注意,Python 2 中的 strunicode 是两种不同的字符串类型。str 本质上保存的是字节数据,而 unicode 保存的是 Unicode 字符。

把上面的程序文件编码改成 UTF-8 或其它编码,可以看到 str 字符串字节内容会随源文件编码发生变化,而 Unicode 字符串本身的 Unicode 编码值并不会因此改变。

简单来说:

  1. Python 2 中,普通 str 字符串保存的是字节数据;
  2. unicode 字符串保存的是 Unicode 字符;
  3. 源代码文件的编码会影响字符串字面量产生的字节内容;
  4. Unicode 字符串本身不依赖源文件编码保存字符。

编码转换

Python 提供 encodedecode 两个方法处理字符串编码。

encode 是将 Unicode 字符编码成指定的字节编码;decode 则是将指定编码的字节数据解码成 Unicode。

例如:

#!/usr/bin/env python
# -*- coding:utf-8 -*-

stra = "Hello"

print stra.encode("gbk").encode("hex")
print stra.decode("utf-8").encode("hex")

这里可以简单理解为:

字节数据 --decode--> Unicode
Unicode --encode--> 指定编码的字节数据

所以,在不同编码之间转换时,一般应该先 decode 成 Unicode,再 encode 成目标编码。

Python代码转换原理

Unicode 字符使用一个统一的代码点(code point)表示,范围从 00x10FFFF

当 Unicode 字符需要转换成其它编码时,就需要查找对应的编码规则。

以 ASCII 为例:

  1. 如果代码点小于 128,可以直接表示;
  2. 如果代码点大于等于 128,则 ASCII 无法表示这个字符,Python 就可能抛出 UnicodeEncodeError

UTF-8 则可以表示所有 Unicode 代码点:

  1. ASCII 范围的字符使用单字节;
  2. 更大的代码点使用多字节表示;
  3. Unicode 中的字符都可以映射到 UTF-8。

UTF-8 的一个重要优势就是兼容 ASCII,并且能够表示 Unicode 中的所有字符。

而对于中文来说,Unicode 转 GBK 则需要根据具体的映射表进行转换。如果目标编码中不存在对应字符,同样会出现编码错误。

Python字符输出流程

前面讲到了 Python 字符串的编码和转换方式。当编码转换过程中遇到目标编码无法表示的字符时,就会抛出 UnicodeEncodeError

那么 Python 是如何输出一个字符的呢?为什么同一个字符在 Windows 下输出可能报错,而在 Linux 下却可以正常输出?

Python IO

Python 提供了多层 IO 结构,用来处理不同层次的数据。其中可以简单理解为:

  • Raw IO:处理原始字节
  • Buffered IO:提供缓冲
  • Text IO:处理字符以及编码和解码

Python IO结构

在 Python 的输出过程中,sys.stdout 会负责将程序输出的数据传递给终端。对于文本输出层来说,它需要按照当前输出环境的编码方式,将 Unicode 字符转换成对应的字节数据。

例如 Windows 中文控制台通常使用 cp936(GBK),如果输出的 Unicode 字符在 GBK 中不存在对应编码,就会出现:

UnicodeEncodeError

而 UTF-8 可以表示所有 Unicode 字符,因此使用 UTF-8 输出时通常不会遇到这类问题。

Unicode Output

所有 Unicode 对象都有 encode 方法。当 Unicode 对象需要输出时,最终都需要转换成目标编码的字节数据。

所以,一个 Unicode 字符从程序内部到最终显示出来,实际上可能经历多次处理:

Unicode对象

encode

输出层

目标编码

终端 / 文件

如果目标编码无法表示当前 Unicode 字符,就会在 encode 的过程中抛出 UnicodeEncodeError

这也就解释了最开始的问题:程序获取到的内容本身没有问题,真正的问题出现在输出阶段。远程内容中包含 U+2022 这样的 Unicode 字符,而 Windows 控制台使用 GBK 编码,GBK 无法表示这个字符,于是 Python 在输出时产生了 UnicodeEncodeError

所以,处理 Unicode 字符串时,一个比较重要的原则就是:

内部尽量统一使用 Unicode,最终输出或保存时,再根据目标环境选择合适的编码。

参考资料

  1. PrintFails
  2. cp936 to Unicode table
  3. source code encoding
  4. standard encodings
  5. Defining Python Source Code Encodings
  6. Encodings
  7. New I/O
  8. io — Core tools for working with streams
  9. 循序渐进全球化

评论0

欢迎分享你的看法,也欢迎补充不同的实践经验。