处理文本的最佳实践是“Unicode 三明治”(如图 4-2 所示)。4 意思是,要尽早把输入(例如读取文件时)的字节序列解码成字符串。这种三明治中的“肉片”是程序的业务逻辑,在这里只能处理字符串对象。在其他处理过程中,一定不能编码或解码。对输出来说,则要尽量晚地把字符串编码成字节序列。多数 Web 框架都是这样做的,使用框架时很少接触字节序列。例如,在 Django 中,视图应该输出 Unicode 字符串;Django 会负责把响应编码成字节序列,而且默认使用 UTF-8 编码。
4我第一次见到“Unicode 三明治”这种说法是在 Ned Batchelder 在 US PyCon 2012 上所做的精彩演讲中:“Pragmatic Unicode”(http://nedbatchelder.com/text/unipain/unipain.html)。

图 4-2:Unicode 三明治——目前处理文本的最佳实践
在 Python 3 中能轻松地采纳 Unicode 三明治的建议,因为内置的 open 函数会在读取文件时做必要的解码,以文本模式写入文件时还会做必要的编码,所以调用 my_file.read() 方法得到的以及传给 my_file.write(text) 方法的都是字符串对象。5
5Python 2.6 或 Python 2.7 用户要使用 io.open() 函数才能得到读写文件时自动执行的解码和编码操作。
可以看出,处理文本文件很简单。但是,如果依赖默认编码,你会遇到麻烦。
看一下示例 4-9 中的控制台会话。你能发现问题吗?
示例 4-9 一个平台上的编码问题(如果在你的机器上运行,它可能会发生,也可能不会)
>>> open('cafe.txt', 'w', encoding='utf_8').write('café')
4
>>> open('cafe.txt').read()
'café'
问题是:写入文件时指定了 UTF-8 编码,但是读取文件时没有这么做,因此 Python 假定要使用系统默认的编码(Windows 1252),于是文件的最后一个字节解码成了字符 'é',而不是 'é'。
我是在 Windows 7 中运行示例 4-9 的。在新版 GNU/Linux 或 Mac OS X 中运行同样的语句不会出问题,因为这几个操作系统的默认编码是 UTF-8,让人误以为一切正常。如果打开文件是为了写入,但是没有指定编码参数,会使用区域设置中的默认编码,而且使用那个编码也能正确读取文件。但是,如果脚本要生成文件,而字节的内容取决于平台或同一平台中的区域设置,那么就可能导致兼容问题。
需要在多台设备中或多种场合下运行的代码,一定不能依赖默认编码。打开文件时始终应该明确传入
encoding=参数,因为不同的设备使用的默认编码可能不同,有时隔一天也会发生变化。
示例 4-9 中有个奇怪的细节:第一个语句中的 write 函数报告写入了 4 个字符,但是下一行读取时却得到了 5 个字符。示例 4-10 是对示例 4-9 的扩展,对这个问题以及其他细节做了说明。
示例 4-10 仔细分析在 Windows 中运行的示例 4-9,找出并修正问题
>>> fp = open('cafe.txt', 'w', encoding='utf_8')
>>> fp ➊
<_io.TextIOWrapper name='cafe.txt' mode='w' encoding='utf_8'>
>>> fp.write('café')
4 ➋
>>> fp.close()
>>> import os
>>> os.stat('cafe.txt').st_size
5 ➌
>>> fp2 = open('cafe.txt')
>>> fp2 ➍
<_io.TextIOWrapper name='cafe.txt' mode='r' encoding='cp1252'>
>>> fp2.encoding ➎
'cp1252'
>>> fp2.read()
'café' ➏
>>> fp3 = open('cafe.txt', encoding='utf_8') ➐
>>> fp3
<_io.TextIOWrapper name='cafe.txt' mode='r' encoding='utf_8'>
>>> fp3.read()
'café' ➑
>>> fp4 = open('cafe.txt', 'rb') ➒
>>> fp4
<_io.BufferedReader name='cafe.txt'> ➓
>>> fp4.read() ⓫
b'caf\xc3\xa9'
❶ 默认情况下,open 函数采用文本模式,返回一个 TextIOWrapper 对象。
❷ 在 TextIOWrapper 对象上调用 write 方法返回写入的 Unicode 字符数。
❸ os.stat 报告文件中有 5 个字节;UTF-8 编码的 'é' 占两个字节,0xc3 和 0xa9。
❹ 打开文本文件时没有显式指定编码,返回一个 TextIOWrapper 对象,编码是区域设置中的默认值。
❺ TextIOWrapper 对象有个 encoding 属性;查看它,发现这里的编码是 cp1252。
❻ 在 Windows cp1252 编码中,0xc3 字节是“Ô(带波形符的 A),0xa9 字节是版权符号。
❼ 使用正确的编码打开那个文件。
❽ 结果符合预期:得到的是四个 Unicode 字符 'café'。
❾ 'rb' 标志指明在二进制模式中读取文件。
❿ 返回的是 BufferedReader 对象,而不是 TextIOWrapper 对象。
⓫读取返回的字节序列,结果与预期相符。
除非想判断编码,否则不要在二进制模式中打开文本文件;即便如此,也应该使用 Chardet,而不是重新发明轮子(参见 4.4.4 节)。常规代码只应该使用二进制模式打开二进制文件,如光栅图像。
示例 4-10 的问题是,打开文本文件时依赖默认设置。默认设置有许多来源,参见下一节。
有几个设置对 Python I/O 的编码默认值有影响,如示例 4-11 中的 default_encodings.py 脚本所示。
示例 4-11 探索编码默认值
import sys, locale
expressions = """
locale.getpreferredencoding()
type(my_file)
my_file.encoding
sys.stdout.isatty()
sys.stdout.encoding
sys.stdin.isatty()
sys.stdin.encoding
sys.stderr.isatty()
sys.stderr.encoding
sys.getdefaultencoding()
sys.getfilesystemencoding()
"""
my_file = open('dummy', 'w')
for expression in expressions.split():
value = eval(expression)
print(expression.rjust(30), '->', repr(value))
示例 4-11 在 GNU/Linux(Ubuntu 14.04)和 OS X(Mavericks 10.9)中的输出一样,表明这些系统中始终使用 UTF-8:
$ python3 default_encodings.py
locale.getpreferredencoding() -> 'UTF-8'
type(my_file) -> <class '_io.TextIOWrapper'>
my_file.encoding -> 'UTF-8'
sys.stdout.isatty() -> True
sys.stdout.encoding -> 'UTF-8'
sys.stdin.isatty() -> True
sys.stdin.encoding -> 'UTF-8'
sys.stderr.isatty() -> True
sys.stderr.encoding -> 'UTF-8'
sys.getdefaultencoding() -> 'utf-8'
sys.getfilesystemencoding() -> 'utf-8'
然而,在 Windows 中的输出有所不同,如示例 4-12 所示。
示例 4-12 在Windows 7(SP1)巴西版中的 cmd.exe 中输出的默认编码;PowerShell 输出的结果相同
Z:\>chcp ➊
Página de código ativa: 850
Z:\>python default_encodings.py ➋
locale.getpreferredencoding() -> 'cp1252' ➌
type(my_file) -> <class '_io.TextIOWrapper'>
my_file.encoding -> 'cp1252' ➍
sys.stdout.isatty() -> True ➎
sys.stdout.encoding -> 'cp850' ➏
sys.stdin.isatty() -> True
sys.stdin.encoding -> 'cp850'
sys.stderr.isatty() -> True
sys.stderr.encoding -> 'cp850'
sys.getdefaultencoding() -> 'utf-8'
sys.getfilesystemencoding() -> 'mbcs'
➊ chcp 输出当前控制台激活的代码页:850。
➋ 运行 default_encodings.py,把结果输出到控制台。
➌ locale.getpreferredencoding() 是最重要的设置。
➍ 文本文件默认使用 locale.getpreferredencoding()。
➎ 输出到控制台中,因此 sys.stdout.isatty() 返回 True。
➏ 因此,sys.stdout.encoding 与控制台的编码相同。
如果把输出重定向到文件,如下所示:
Z:\>python default_encodings.py > encodings.log
sys.stdout.isatty() 的返回值会变成 False,sys.stdout.encoding 会设为 locale.getpreferredencoding(),在那台设备中是 'cp1252'。
注意,示例 4-12 中有 4 种不同的编码。
如果打开文件时没有指定 encoding 参数,默认值由 locale.getpreferredencoding() 提供(在示例 4-12 中是 'cp1252')。
如果设定了 PYTHONIOENCODING 环境变量(https://docs.python.org/3/using/cmdline.html#envvar-PYTHONIOENCODING),sys.stdout/stdin/stderr 的编码使用设定的值;否则,继承自所在的控制台;如果输入 / 输出重定向到文件,则由 locale.getpreferredencoding() 定义。
Python 在二进制数据和字符串之间转换时,内部使用 sys.getdefaultencoding() 获得的编码;Python 3 很少如此,但仍有发生。6 这个设置不能修改。7
sys.getfilesystemencoding() 用于编解码文件名(不是文件内容)。把字符串参数作为文件名传给 open() 函数时就会使用它;如果传入的文件名参数是字节序列,那就不经改动直接传给 OS API。“Unicode HOWTO”一文(https://docs.python.org/3/howto/unicode.html)中说:“在 Windows 中,Python 使用 mbcs 这个名称引用当前配置的编码。”MBCS 是 Multi Byte Character Set(多字节字符集)的首字母缩写,在 Windows 中是陈旧的变长编码,如 gb2312 或 Shift_JIS,而不是 UTF-8。[ 关于这个话题,Stack Overflow 中有一个很好的回答“,Difference between MBCS and UTF-8 on Windows”(http://stackoverflow.com/questions/3298569/difference-between-mbcs-and-utf-8-on-windows)。]
6研究这个话题时,我在 Python 内部找不到把字节序列转换成字符串的情况。Python 核心开发者 Antoine Pitrou 在 comp.python.devel 邮件列表中说(http://article.gmane.org/gmane.comp.python.devel/110036),CPython 的内部函数“在 py3k 中很少这么做”。
7Python 2 对 sys.setdefaultencoding 函数的使用方式不当,Python 3 的文档中已经没有这个函数。这个函数是供核心开发者使用的,用于在内部的默认编码未定时设置编码。在 comp.python.devel 邮件列表的那个话题中(http://article.gmane.org/gmane.comp.python.devel/109916),Marc-André Lemburg 说,用户代码一定不能调用 sys.setdefaultencoding 函数,而且对 CPython 来说,它的值在 Python 2 中只能是 'ascii',在 Python 3 中只能是 'utf-8'。
在 GNU/Linux 和 OS X 中,这些编码的默认值都是 UTF-8,而且多年来都是如此,因此 I/O 能处理所有 Unicode 字符。在 Windows 中,不仅同一个系统中使用不同的编码,还有只支持 ASCII 和 127 个额外的字符的代码页(如
'cp850'或'cp1252'),而且不同的代码页之间增加的字符也有所不同。因此,若不多加小心,Windows 用户更容易遇到编码问题。
综上,locale.getpreferredencoding() 返回的编码是最重要的:这是打开文件的默认编码,也是重定向到文件的 sys.stdout/stdin/stderr 的默认编码。然而,文档也说道(摘录部分,https://docs.python.org/3/library/locale.html#locale.getpreferredencoding):
locale.getpreferredencoding(do_setlocale=True)根据用户的偏好设置,返回文本数据的编码。用户的偏好设置在不同系统中的设定方式不同,而且在某些系统中可能无法通过编程方式设置,因此这个函数返回的只是猜测的编码……
因此,关于编码默认值的最佳建议是:别依赖默认值。
如果遵从 Unicode 三明治的建议,而且始终在程序中显式指定编码,那将避免很多问题。可惜,即使把字节序列正确地转换成字符串,Unicode 仍有不尽如人意的地方。接下来的两节讨论的话题对 ASCII 世界来说很简单,但是在 Unicode 领域就变得相当复杂:文本规范化(即为了比较而把文本转换成统一的表述)和排序。