本章将讨论以下话题:

  • 字符、码位和字节表述
  • bytes、bytearray 和 memoryview 等二进制序列的独特特性
  • 全部 Unicode 和陈旧字符集的编解码器
  • 避免和处理编码错误
  • 处理文本文件的最佳实践
  • 默认编码陷阱和标准 I/O 问题
  • 规范化 Unicode 文本,进行安全的比较
  • 规范化、大小写折叠和暴力移除音调符号的实用函数
  • 实用 locale 模块和 PyUCA 库正确地排序 Unicode 文本
  • Unicode 数据库中的字符元数据
  • 能处理字符串和字节序列的双模式 API

(有一些东西觉得用不到,就没有记,到时候用到可以对照目录看书)

字符问题

字符串是个简单的概念,一个字符序列,问题出现在 “字符” 的定义上。在 2015 年 “字符” 的最佳定义是 Unicode 字符,因此,从 Python 3 的 str 对象获得的元素是 Unicode 字符,这相当于从 Python 2 中的 unicode 对象中获取的元素,而不是从 Python 2 中的 str 对象获取原始字节序列。

把码位转成字节序列的过程叫编码,把字节序列转换成码位的过程是解码。下面展示了这一区分:


In [1]:
s = 'safé' # 一共有 4 个 Unicode 对象
len(s)


Out[1]:
4

In [2]:
b = s.encode('utf8') # 使用 UTF-8 把 str 对象编码成 bytes 对象
b


Out[2]:
b'saf\xc3\xa9'

In [3]:
len(b) # 在 UTF-8 中, é 编码成字节


Out[3]:
5

In [4]:
b.decode('utf8') #使用 UTF-8 把 bytes 对象解码成 str 对象


Out[4]:
'safé'

如果想帮自己记住 .decode() 和 .encode() 的区别,可以把字节序列想象成晦涩难懂的机器磁芯转储,把 Unicode 字符串想象成 “人类可读” 的文本,那么,把字节序列变成人类可读的文本字符串就是编码,把字符串变成用于存储或传输的字节序就是编码

虽然 Python 3 中的 str 类型相当于 Python 2 中的 unicode 类型,只不过换了个名称,不过 Python 3 中的 bytes 类型却不是把 str 类型换个名称那么简单,而且还有关系紧密的 bytearray 类型。因此,在讨论编码和解码问题之前,有必要来介绍一下二进制序列类型

字节概要

新的二进制序列类型在很多方面与 Python 2 中的 str 类型不同, 首先要知道,Pyhon 内置了两种基本二进制序列类型,Python 3 引入的不可变 bytes 和 Python 2.6 添加的可变 bytearray 类型(Python 2.6 也有 bytes 类型,不过那是 str 类型的别名,与 Python 3 中的 bytes 类型不同)

bytes 或 bytearray 对象的各个元素是介于 0-255(含 255) 的整数,而不像 Python 2 的 str 对象那样是单个的字符。然而,二进制序列的切片始终是同一类型的二进制序列,包括长度为 1 的切片:


In [1]:
cafe = bytes('café', encoding = 'utf_8')
cafe


Out[1]:
b'caf\xc3\xa9'

In [2]:
cafe[0]  # 每个元素都是 range(256) 的整数


Out[2]:
99

In [3]:
cafe[:1] # bytes 对象的切片还是 bytes,即使只有一个元素的切片


Out[3]:
b'c'

In [4]:
cafe_arr = bytearray(cafe) 
cafe_arr #bytearray 没有字面量语法,而是以 bytearray() 和字节序列字面量参数形式显示


Out[4]:
bytearray(b'caf\xc3\xa9')

In [5]:
cafe_arr[-1:] # bytearray 对象切片还是 bytearray 对象


Out[5]:
bytearray(b'\xa9')

my_bytes[0] 获取的是一个整数,而 my_bytes[:1] 获取的是长度为 1 的 bytes 对象。 s[0] == s[:1] 只对 str 序列类型成立,不过 str 这个行为很罕见。对于其他各个序列类型来说,s[i] 返回的是一个元素,s[i:i+1] 返回的是一个相同类型的序列,里面是 s[i] 元素

虽然二进制序列其实是整数序列,但它们的字面量表示法表明其中有 ASCII 文本。因此,各个字节的值可能会用下面 3 种不同的方式显示

  • 可打印的 ASCII 范围内的字节(从 空格 到 ~),使用 ASCII 字符本身
  • 制表符、换行符、回车符和 \ 对应的字节,使用转义序列 \t、\n、\r、和 \
  • 其它字节的值,使用十六进制转义序列(例如,\x00 是空字节)

因此在上面我们看到 b'caf\xc3\xa9' 前 3 个字符在可打印 ASCII 范围内,后面不在

除了格式化方法(format 和 format_map)和几个处理 Unicode 数据的方法(包括 casefold, isdecimal, isidentifier, isnumeric, isprintable, encode) 之外,str 类型的其它方法都支持 bytes 和 bytearray 类型。这意味着,我们可以使用熟悉的方式处理二进制序列,如 endswith, replace, strip, translate, upper 等,只有少数几个其它方法参数是 bytes 对象而不是 str 对象。此外如果正则表达式编译自二进制序列而不是字符串,re 模块中的正则表达式函数也能处理二进制序列。

二进制序列有个类方法是是 str 类型没有的,叫做 fromhex,它的作用是解析十六进制数字对(数字对之间的空格是可选的),构建二进制序列:


In [14]:
bytes.fromhex('31 4b ce a9')


Out[14]:
b'1K\xce\xa9'

构建 bytes 或 bytearray 实例还可以调用各自的构造方法,传入以下参数

  • 一个 str 对象和一个 encodeing 关键字参数
  • 一个可迭代对象,提供 0-255 之间的值
  • 一个整数,使用空字节创建对应长度的二进制序列。[ Python 3.5 把这个方法标记为 过时的,不建议用 ]
  • 一个实现了缓冲协议的对象(如 bytes, bytearray, memoryview, array.array);此时,把源对象中的字节序列复制到新建的二进制序列中

使用缓冲类对象构建二进制序列是一种底层操作,可能涉及类型转换,下面做了演示:


In [16]:
import array
numbers = array.array('h', [-2, -1, 0, 1, 2]) # h 表示短整数( 16 位)数组
octets = bytes(numbers)
octets # 这是表示 5 个短整数的 10 个字节


Out[16]:
b'\xfe\xff\xff\xff\x00\x00\x01\x00\x02\x00'

使用缓冲类对象创建 bytes 或 bytearray 对象时,始终复制源对象中的字节序。与之相反,memoryview 对象允许在二进制数据结构之间共享内存。如果想从二进制序列中提取结构化信息,struct 模块是重要的工具。下面会用这个模块处理 bytes 和 memoryview 对象

结构体和内存视图

struct 模块提供了一些函数,把打包字节序列转换成不同类型字段组成的元组,或有一些函数用于执行反向转换,把元组转换成打包的字节序列。struct 模块能够处理 bytes、bytearray 和 memoryview 对象

第二章讲过 memoryview 类不是用于创建或存储字节序列的,而是共享内存,让你访问其他二进制序列、打包的数组和缓冲区中的数据切片,无需复制字节序列,例如 Python Imaging Library(PIL) 就是这样处理图像的

下面展示了提取一个 GIF 图像的宽度和高度:


In [17]:
import struct 
fmt = '<3s3sHH'  # < 是小端字节序,3s3s 是两个 3 字节序列,HH 是两个 16 位二进制整数
with open("/home/kaka/Downloads/giphy.gif", "rb") as fp:
    img = memoryview(fp.read()) # 使用内存中的文件内容创建一个 memoryview 对象,这里不会复制字节序列
header = img[:10] #使用它的切片再创建一个 memoryview 对象,这里不会复制字节序列
bytes(header) # 转成字节序列,这里只是为了显示,这里只复制了 10 个字节


Out[17]:
b'GIF89a\xf4\x01\xa6\x01'

In [18]:
struct.unpack(fmt, header) # 拆包 memoryview 对象,得到一个元组,包含类型、版本、宽度和高度


Out[18]:
(b'GIF', b'89a', 500, 422)

In [20]:
del header  # 删除引用,释放 memoryview 实例所占的内存
del img

注意,memoryview 对象的切片是一个新的 memoryview 对象,不会复制字节序列。如果使用 mmap 模块把图像打开为内存映射文件,那么会复制少量字节,这里不讨论。如果你经常读取修改二进制文件可以查一下资料。

基本的编解码器

Python 自带了超过 100 种编解码器,用于文本和字节之间的转换。每个编解码器都有一个名称,例如 'utf_8',而且经常有几个别名,如 'utf8', 'utf-8' 和 'U8'。这些名称可以传给 open()、str.encode()、bytes.decode() 等函数的 encoding 参数。下面展示了使用 3 个编解码器把相同文本编码成不同的字节序列


In [21]:
for codec in ['latin_1', 'utf_8', 'utf_16']:
    print(codec, 'El Niño'.encode(codec), sep = '\t') # sep 是分隔符,默认是空格


latin_1	b'El Ni\xf1o'
utf_8	b'El Ni\xc3\xb1o'
utf_16	b'\xff\xfeE\x00l\x00 \x00N\x00i\x00\xf1\x00o\x00'

了解编解码问题

虽然有个一般性的 UnicodeError 异常,但是报告错误时几乎都会指明具体的异常:UnicodeEnocdeError(把字符串转换成二进制序列时)或 UnicodeDecodeError(把二进制序列转换成字符串时)。如果源码的编码与预期不符,加载 Python 模块时还可能抛出 SyntaxError。接下来说明如何处理这几种错误。

处理 UnicodeEncodeError

多数非 UTF 编解码器只能处理 Unicode 字符的一部分子集。把文本转成字节序列时,如果目标编码中没有定义某个字符,就会抛出 UnicodeEncodeError 异常,除非把 errors 参数传给编码方法或函数,对错误进行特殊处理。


In [22]:
city = "SãoPaulo"
city.encode('utf_8') # utf_? 能处理任何字符串


Out[22]:
b'S\xc3\xa3oPaulo'

In [24]:
city.encode('utf_16')


Out[24]:
b'\xff\xfeS\x00\xe3\x00o\x00P\x00a\x00u\x00l\x00o\x00'

In [25]:
city.encode('iso8859_1')


Out[25]:
b'S\xe3oPaulo'

In [26]:
city.encode('cp437') # 无法编码 ã,默认的错误处理方式是 'strict' 抛出 UnicodeEncodeError


---------------------------------------------------------------------------
UnicodeEncodeError                        Traceback (most recent call last)
<ipython-input-26-768485688c3d> in <module>()
----> 1 city.encode('cp437')

/home/kaka/anaconda2/envs/py35/lib/python3.5/encodings/cp437.py in encode(self, input, errors)
     10 
     11     def encode(self,input,errors='strict'):
---> 12         return codecs.charmap_encode(input,errors,encoding_map)
     13 
     14     def decode(self,input,errors='strict'):

UnicodeEncodeError: 'charmap' codec can't encode character '\xe3' in position 1: character maps to <undefined>

In [27]:
city.encode('cp437', errors = 'ignore') # 跳过无法编码的字符,这种做法通常要出大问题


Out[27]:
b'SoPaulo'

In [28]:
city.encode('cp437', errors = 'replace') # 把无法编码的字符替换成 '?',数据会损坏了,但是用户知道出了问题


Out[28]:
b'S?oPaulo'

In [29]:
city.encode('cp437', errors = 'xmlcharrefreplace') # 将无法编码的字符串换成 XML 实体


Out[29]:
b'S&#227;oPaulo'

编解码的错误处理方式是可扩展的,你可以为 errors 参数注册额外的字串,方法是把一个名称和一个错误处理函数传给 codecs.register_error 函数。

处理 UnicodeDecodeError

不是每一个字节都包含有效的 ASCII 字符,也不是每一个字符序列都是有效的 UTF_8 或者 UTF_16。因此,把二进制序列转换成文本时,如果假设这两个编码中的一个,遇到无法转换的字节序列会抛出 UnicodeDecodeError。另一方面,很多陈旧的 8 位编码 -- 如 'cp1252'、'iso8859_1' 和 'koi8_r' 能解码任何字节序列流而不抛出错误,例如随机噪声。因此如果程序使用错误的 8 位编码,解码过程悄无声息,得到的是无用输出(乱码称为 “鬼符”,gremlin 或 mojibake)。

下面展示了使用错误的编解码器可能出现鬼符或抛出 UnicodeDecodeError


In [30]:
octets = b'Montr\xe9al' # 这个字节序列使用 latin1 编码的 Montréal
octets.decode('cp1252') # 可以使用 cp1252,因为它是 latin1 的超集


Out[30]:
'Montréal'

In [31]:
octets.decode('iso8859_7') # iso8859_7 用于编码希腊文,因此无法正确解释 \xe9 字节,而且没抛出错误


Out[31]:
'Montrιal'

In [32]:
octets.decode('koi8_r') # 编码俄文,同样无法正确解释 \xe9,没抛出错误


Out[32]:
'MontrИal'

In [33]:
octets.decode('utf_8') # utf_8 检测到 这不是有效的 utf_8 编码,抛出 UnicodeDecodeError 错误


---------------------------------------------------------------------------
UnicodeDecodeError                        Traceback (most recent call last)
<ipython-input-33-f3a91f0d51e5> in <module>()
----> 1 octets.decode('utf_8')

UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 5: invalid continuation byte

In [34]:
octets.decode('utf_8',  errors = 'replace') # 使用 replace 的错误处理方式


Out[34]:
'Montr�al'

使用预期之外的编码加载模块时抛出 SyntaxError

Python 3 默认使用 UTF-8 编码源码, Python 2(从 2.5 开始)默认使用 ASCII,如果加载的 .py 模块中包含 UTF-8 之外的数据,而且没有声明编码,会得到类似下面的信息:

SyntaxError: Non-UTF-8 code starting with '\xc1' in file C:...\xxx.py on line 8, but no encoding declared; see http://python.org/dev/peps/pep-0263/ for details

为了修正这个问题,可以再在文件顶部添加一个神奇的 coding 注释,例如这样:

# coding: utf-8

如何找出字节序的编码

简单的来说,不能,必须有人告诉你

有些通信协议的文件格式,例如 HTTP 和 XML,包含明确指定内容编码的首部。ASCII 编码不会有大于 127 的值,但是也不能以此作为判断是不是 ASCII 编码的依据。然而,就像人类语言也有规则和限制一样,只要假定字节流是人类可读的纯文本,就可能通过试探和分析找出编码。例如,如果 b'\x00' 经常出现,可能是 16 位或 32 位编码,而不是 8 位编码方案,因为纯文本中不能包含空字符。如果字节序列 b'\x20\x00' 经常出现,可能是 UTF-16LE 编码中的空格字符等等。

统一字符编码侦测包 Chardet 就是这样工作的,他能侦测识别所支持的 30 种编码。 Chardet 是一个 Python 库,可以在程序中使用,下面是它对本文源码的检测报告:

4_code.ipynb: utf-8 with confidence 0.99

有用的鬼符

在前面你可能能注意到了,UTF-16 编码的序列开头有几个额外的字节,如下所示


In [35]:
u16 = 'El Niño'.encode('utf_16')
u16


Out[35]:
b'\xff\xfeE\x00l\x00 \x00N\x00i\x00\xf1\x00o\x00'

这里的 b'\xff\xfe' 是 BOM,即 字节序标记(byte-order mark),指明编码时使用 IntelCPU 的小端字节序

小端字节序是低位在前,高位在后。字母 'E' 的编码是 U+0045,十进制数 69,字节的偏移第二位和第三位编码是 69,0(两位代表一个字符)。


In [38]:
list(u16)


Out[38]:
[255, 254, 69, 0, 108, 0, 32, 0, 78, 0, 105, 0, 241, 0, 111, 0]

大端高位在前,低位在后。为了避免混淆,UTF-16编码要在编码文本前加上特殊的不可见字符 ZERO WIDTH NO-BREAK SPACE(U+FEFF)。在小端字节序,这个字符编码为 b'\xff\xfe'(十六进制数 255, 254)。因为按照设计,U+FEFF 字符不存在,在小字节序编码中,字节序 b'\xff\xfe' 必定是 ZERO WIDTH NO-BREAK SPACE,所以编码器知道要用哪个字节序

UTF-16 有两个变种:UTF-16LE,显式指定使用小字节序;UTF-16BE,显示指定使用大字节序,如果使用这两个变种,不会生成 BOM


In [40]:
u16 = 'El Niño'.encode('utf_16le')
list(u16)


Out[40]:
[69, 0, 108, 0, 32, 0, 78, 0, 105, 0, 241, 0, 111, 0]

In [41]:
u16 = 'El Niño'.encode('utf_16be')
list(u16)


Out[41]:
[0, 69, 0, 108, 0, 32, 0, 78, 0, 105, 0, 241, 0, 111]

根据标准,如果文件使用 UTF-16 编码,而没有 BOM,假定它使用 UTF-16BE 编码,然而,Intel x86 架构使用小字节序,因此有很多文件用的是不带 BOM 的小字节序 UTF-16 编码

处理文本文件

处理文本文件最佳方法是 “Unicode 三明治” 方法,尽早的把输入(例如读取文件)的字节序列解码成字符串,业务逻辑处理的是字符串对象,输出要尽可能晚的将字符串编码成字节序列

Python 3 中可以轻松做到这点,因为内置的 open 函数会在读取文件时做必要的解码,以文本模式写入文件时还会做必要的编码,所以调用 my_file.read() 方法得到的以及传给 my_file.write(text) 方法的都是字符串对象

可以看到,处理文本文件很简单,但是如果依赖默认编码可能会遇到麻烦:


In [42]:
open('cafe.txt', 'w', encoding = 'utf_8').write('café')


Out[42]:
4

In [43]:
open('cafe.txt').read() # 这个在 Windows 下可能会出现问题,因为 Windows 系统默认编码可能是 cp1252


Out[43]:
'café'

需要在多台设备或多种场合运行的代码,一定不能依赖默认编码,打开文件时候应该明确传入 encoding= 参数。

为了正确比较而规范化 Unicode 字符串

因为 Unicode 有组合字符(变音符号和附加到前一个字符上的记号,打印时作为一个整体),所以字符串比较起来很复杂

例如 'café' 这个词可以使用两种方式构成,分别有 4 个和 5 个码位,但结果一样:


In [44]:
s1 = 'café'
s2 = 'cafe\u0301'
s1, s2


Out[44]:
('café', 'café')

In [45]:
len(s1), len(s2)


Out[45]:
(4, 5)

In [46]:
s1 == s2


Out[46]:
False

U+0301 是 COMBINING ACUTE ACCENT,加在 “e“ 后面得到 ”é“。在 Unicode 标准中, 'é' 和 'e\u030' 这样的序列叫做 ”标准等价物“,应用程序应该把它们看做相同的字符,但是,Python 看到的是不同的码位序列,因此判定二者不等

这个问题的解决方案是使用 unicodedata.normalize 函数提供的 Unicode 规范化。这个函数的第一个参数是这 4 个字符串其中的一个: 'NFC', 'NFD', 'NFKC', 'NFKD'。下面说明前两个

NFC(Normalization Form C) 使用最少的码位构成等价的字符串,而 NFD 把组合字符分解成基字符和单独的组合字符,这两种规范化方式都可以得到预期的结果:


In [47]:
from unicodedata import normalize
s1 = 'café'
s2 = 'cafe\u0301'
len(s1), len(s2)


Out[47]:
(4, 5)

In [48]:
len(normalize('NFC', s1)), len(normalize('NFC', s2))


Out[48]:
(4, 4)

In [49]:
len(normalize('NFD', s1)), len(normalize('NFD', s2))


Out[49]:
(5, 5)

In [50]:
normalize('NFC', s1) == normalize('NFC', s2)


Out[50]:
True

In [51]:
normalize('NFD', s1) == normalize('NFD', s2)


Out[51]:
True

西方键盘通常能输出组合字符,因此用户输入的文本默认是 NFC 形式,不过为了保险,保存文本之前,最好使用 normalize('NFC', user_text) 清洗字符串。

在另外两个规范化形式(NFKC 和 NFKD)的首字母缩略词中,字母 K 表示 ”compatibility“(兼容性)。这两种是较严格的规范化形式,对 “兼容性字符” 有影响,虽然 Unicode 是为各个字符提供 “规范化” 码位,但是为了兼容现有的标准,有些字符会出现多次,例如虽然希腊字母表中有 "μ" 这个字母(码位是 U+03BC, GREEK SMALL LETTER MU),但是 Unicode 还是加了微符号 µ (U+00B5) 为了和 latin1 相互转换,因此,微符号是一个 “兼容字符”

在 NFKC 和 NFKD 中,各个兼容字符会被替换成一个或多个 “兼容分解” 字符,即便这样有些格式损失,但仍是 “首选” 表述 -- 理想情况下,格式化是外部标记的职责,不应该由 Unicode 处理。下面举个例子。 二分之一 '½'(U+00BD)经过兼容分解后得到的是三个字符序列 '1/2';微符号 μ 分解后是小写字母 μ (U+03BC)

下面是具体应用:


In [52]:
from unicodedata import normalize, name
half = '½'
normalize('NFKC', half)


Out[52]:
'1⁄2'

注意,在 NFKC 或 NFKD 中可能会损失或曲解信息,例如字符$4^{2}$ 就被转换成 42,损失了原意,但是可以为搜索和索引提供便利的中间表述,例如 用户搜索 '1 / 2 inch' 搜到了 '½ inch' 会非常满意

使用 NFKC 和 NFKD 要小心,而且只能在特殊情况使用,例如搜索和索引,而不能用于持久存储,因为这两种转换会造成数据损失

os 函数中的字符串和字节序列

GNU/Linux 内核不理解 Unicode,因此对于任何合理的编码方案来说,文件名中使用字节序列都是无效的,无法解码成字符串。在不同操作系统中使用各种客户端的文件服务器,在遇到这个问题更容易出错

为了规避这个问题,os 模块的所有函数,文件名或者路径名参数既能使用字符串又能使用字节序列。如果这样的函数使用字符串参数调用,该参数会使用 sys.getfilesystemencoding() 得到编解码器的自动编码,然后操作系统会使用相同的编解码器解码。这几乎就是我们想要的行为,与 Unicode 三明治最佳实践一致

如果必须处理(或者修正)黁写无法使用上述方式自动处理的文件名,可以把啊字节序列参数传给 os 模块中的函数,得到字节序列返回值。这一特性允许我们处理任何文件名或路径名,不管里面有多少鬼符。


In [54]:
import os
os.listdir('./test')


Out[54]:
['cafe.txt', 'digits-of-π.txt']

In [55]:
os.listdir(b'./test') # \xcf\x80 是 π 的 UTF-8 编码


Out[55]:
[b'cafe.txt', b'digits-of-\xcf\x80.txt']

为了便于手动处理字符串或字节序列形式的文件名或路径名,os 模块提供了特殊的编码和解码函数

fsencode(filename)

如果 filename 是 str 类型(此外还可能是 bytes 类型),使用 sys.getfilesystemencoding() 返回的编解码器把 filename 编码成字节序列,否则,返回未经修改的 filename 字节序列

fsdecode(filename)

如果 filename 是 decode 类型(此外还可能是 str 类型),使用 sys.getfilesystemencoding() 返回的编解码器把 filename 解码成字符串,否则,返回未经修改的 filename 字符串序列

在 Unix 衍生平台,这些函数使用 surrogateescape 错误处理方式,避免遇到意外字节序列时候卡住,Windows 用的是 strict 方式

surrogateescape 会把每个无法解码的字节换成 Unicode 中的 U+DC00 到 U+DCFF 之间的码位,这些码位是保留的,没有分配支付,供程序内部使用。编码时,这些码位会转换成被替换的字节值,如下


In [56]:
os.listdir('./test')


Out[56]:
['cafe.txt', 'digits-of-π.txt']

In [57]:
os.listdir(b'./test')


Out[57]:
[b'cafe.txt', b'digits-of-\xcf\x80.txt']

In [58]:
pi_name_bytes = os.listdir(b'./test')[1]
pi_name_str = pi_name_bytes.decode('ascii', 'surrogateescape') # 使用 ascii 和 surrogateescape 错误处理方式把它解码成字符串
pi_name_str


Out[58]:
'digits-of-\udccf\udc80.txt'

In [59]:
pi_name_str.encode('ascii', 'surrogateescape') # 再用同样的方式编码回原始值


Out[59]:
b'digits-of-\xcf\x80.txt'