0%

流畅的 Python 第 2 版(4):Unicode 文本和字节序列

文本给人类阅读,字节序列供计算机处理。Python3 明确区分了人类可读的文本字符串和原始的字节序列。把字节序列隐式转换成 Unicode 文本已成过去。

字符问题

字符串 是个相当简单的概念:一个字符串就是一个字符序列。问题出在 字符 的定义上。目前,字符 的最佳定义是 Unicode 字符。因此,从 Python3 的 str 对象中获取的项是 Unicode 字符。Unicode 标准明确区分字符的标识和具体的字节表述:

  • 字符的标识,即码点:在 Unicode 标准中以 4~6 个十六进制数表示,前加 U+​,取值范围是 U+0000~U+10FFFF
  • 字符的具体表述取决于所用的编码,编码是在码点和字节序列之间转换时使用的算法。例如字母 A(U+0041)在 UTF-8 编码中使用单个字节 \x41 表述,而在 UTF-16LE 编码中使用字节序列 \x41\x00 表述

把码点转换成字节序列的过程叫编码,把字节序列转换成码点的过程叫解码。
python

1
2
3
4
5
6
7
8
9
10
>>> s = 'café'
>>> len(s)
4
>>> b = s.encode('utf8')
>>> b
b'caf\xc3\xa9'
>>> len(b)
5
>>> b.decode('utf-8')
'café'
  • 将 str 对象编码后,会得到 bytes 对象
  • bytes 字面量以 b 开头

字节概要

bytes 和 bytearray 中的项是 0~255(含)的整数,二进制序列的切片始终是同一类型的二进制序列,包括长度为 1 的切片:

1
2
3
4
5
6
7
8
9
10
11
12
>>> b
b'caf\xc3\xa9'
>>> b[0]
99
>>> b[:1]
b'c'

>>> cafe_arr = bytearray(b)
>>> cafe_arr
bytearray(b'caf\xc3\xa9')
>>> cafe_arr[:1]
bytearray(b'c')

b[0] 获取的是一个整数,而 b[:1] 返回的是一个长度为 1 的字节序列。对 str 类型来说,s[0]==s[:1]​。除此之外,对于 Python 中的其他所有序列类型,一个项不能等同于长度为 1 的切片。

在 str 类型的一众方法中,除了格式化方法 formatformat_map,以及处理 Unicode 数据的方法 casefold、isdecimal、isidentifier、isnumeric、isprintable 和 encode 之外,其他方法均受 bytes 和 bytearray 类型的支持。另外,如果正则表达式编译自二进制序列而不是字符串,那么 re 模块中的正则表达式函数也能处理二进制序列。二进制序列有一个类方法是 str 没有的,名为 fromhex,它的作用是解析十六进制数字对(数字对之间的空格是可选的)​,构建二进制序列。

1
2
>>> bytes.fromhex('31 4B CE A9')
b'1K\xce\xa9'

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

  • 一个 str 对象和 encoding 关键字参数
  • 一个可迭代对象,项为 0~255 范围内的数
  • 一个实现了缓冲协议的对象(例如 bytes、bytearray、memoryview、array.array)。构造函数把源对象中的字节序列复制到新创建的二进制序列中
1
2
3
4
5
>>> import array
>>> numbers = array.array('h', [-2, -1, 0, 1, 2])
>>> b = bytes(numbers)
>>> b
b'\xfe\xff\xff\xff\x00\x00\x01\x00\x02\x00'

使用缓冲类对象创建 bytes 或 bytearray 对象,始终复制源对象中的字节序列。与之相反,memoryview 对象在二进制数据结构之间共享内存。

基本的编码解码器

Python 自带超过 100 种编码解码器(codec,encoder/decoder),用于在文本和字节之间相互转换。某些编码(例如 ASCII 和多字节的 GB2312)不能表示所有 Unicode 字符。然而,UTF 编码的设计目的就是处理每一个 Unicode 码点

处理编码和解码问题

出现与 Unicode 有关的错误时,首先要明确异常的类型。要知道导致编码问题的究竟是 UnicodeEncodeError、UnicodeDecodeError,还是其他错误(例如 SyntaxError)​。解决问题之前必须清楚这一点。

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

ASCII 是所有编码的共同子集,因此,只要文本全是 ASCII 字符,编码就一定能成功。str.isascii() 用于检查 Unicode 文本是不是全部由 ASCII 字符构成。

在对字节序列进行解码时,如果指定的编码协议无法正确解码字节序列,会抛出 UnicodeDecodeError。解码方法也支持通过 errors 参数指定解码出错时的行为。

Python3 默认使用 UTF-8 编码源码。如果加载的 .py 模块中包含 UTF-8 之外的数据,而且没有声明编码,那么将看到类似下面的消息

1
2
3
SyntaxError: Non-UTF-8 code starting with '\xe1' in file ola.py on line
1, but no encoding declared; see https://python.org/dev/peps/pep-0263/
for details

为了解决这个问题,可以在文件顶部添加一个神奇的 coding 注释:

1
2
3
# coding: cp1252

print('Olá, Mundo!')

如何自己找出字节序列的编码呢?简单来说,不能。这只能由别人来告诉你。有些通信协议和文件格式,例如 HTTP 和 XML,通过首部明确指明内容编码。然而,就像人类语言也有规则和限制一样,只要假定字节流是人类可读的纯文本,就可能通过试探和分析找出编码。统一字符编码侦测包 Chardet 就是这样工作的,它能识别所支持的 30 种编码。

另外,字节序对一个字(word)占多个字节的编码(例如 UTF-16 和 UTF-32)有影响。UTF-8 的一大优势是,不管设备使用哪种字节序,生成的字节序列始终一致,因此不需要 BOM。

处理文本文件

目前处理文本的最佳实践是 Unicode三明治 原则:

  • 根据这个原则,我们应当尽早把输入的 bytes(例如读取文件得到)解码成 str
  • 程序的业务逻辑,在这里只能处理 str 对象
  • 对输出来说,则要尽量晚地把 str 编码成 bytes

多数 Web 框架是这样做的,在使用框架的过程中,我们很少接触到 bytes。在 Python3 中,我们可以轻松地采纳 Unicode三明治 的建议,因为:

  • 内置函数 open() 在读取文件时会做必要的解码
  • 以文本模式写入文件时还会做必要的编码
  • 所以调用 my_file.read() 方法得到的以及传给 my_file.write(text) 方法的都是 str 对象

但是需要在多台设备中或多种场合下运行的代码,一定不能依赖默认编码。打开文件时始终应该明确传入 encoding=参数,因为不同的设备使用的默认编码可能不同。

另外说明一下,open 函数默认采用文本模式,返回一个使用指定方式编码的 TextIOWrapper 对象,而如果以rb 标志指明以二进制模式读取文件,返回一个 BufferedReader 对象,而不是 TextIOWrapper 对象。

在Python中,I/O 默认使用的编码受到多个设置的影响,当然最重要的还是 locale.getpreferredencoding()因此,关于默认编码的最佳建议:别依赖默认编码

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

因为 Unicode 有组合字符(变音符和附加到前一个字符上的其他记号,打印时作为一个整体)​,所以字符串比较起来很复杂。例如,​café 这个词可以使用两种方式构成,分别有 4 个和 5 个码点,虽然结果看起来完全一样,但是 Python 看到的是不同的码点序列,因此判定二者不相等。

1
2
3
4
5
6
7
8
>>> s1 = 'café'
>>> s2 = 'cafe\N{COMBINING ACUTE ACCENT}'
>>> s1, s2
('café', 'café')
>>> len(s1), len(s2)
(4, 5)
>>> s1 == s2
False

这个问题的解决方案是使用 unicodedata.normalize() 函数。该函数的第一个参数是 NFCNFDNFKCNFKD 这 4 个字符串中的一个:

  • NFC(Normalization Form C)使用最少的码点构成等价的字符串
  • NFD 把合成字符分解成基字符和单独的组合字符
  • 另外两种规范化形式 NFKC 和 NFKD,字母 K 表示 compatibility​(兼容性)​。这两种规范化形式较为严格,对所谓的 兼容字符 有影响

键盘驱动通常能输出合成字符,因此用户输入的文本默认是 NFC 形式。不过,安全起见,保存文本之前,最好使用 normalize('NFC', user_text) 规范化字符串。

为搜索或索引准备文本时,还有一个有用的操作。大小写同一化其实就是把所有文本变成小写,再做些其他转换。这个操作由 str.casefold() 方法实现。

除了 Unicode 规范化和大小写同一化(均由 Unicode 标准规定)之外,有时需要进行更为深入的转换,例如去掉变音符。我们只有知道目标语言、目标用户群和转换后的用途,才能确定要不要做这么深入的规范化。

Unicode 文本排序

给任何类型的序列排序,Python 都会逐一比较序列中的每一项。对字符串来说,比较的是码点。可是,一旦遇到非 ASCII 字符,结果就往往不尽如人意。在 Python 中,非 ASCII 文本的标准排序方式是使用 locale.strxfrm 函数。根据 locale 模块的文档,这个函数 把字符串转换成适合所在区域进行比较的形式。使用 locale.strxfrm 函数之前,必须先为应用设定合适的区域设置。

pyuca 库单纯使用 Python 实现了 Unicode 排序算法(Unicode Collation Algorithm,UCA)。pyuca 不考虑区域设置。如果你想自定义排序方式,那么可以把自定义的排序表路径传给 Collator() 构造函数。pyuca默认使用项目自带的 allkeys.txt,这是 Unicode 网站中默认 Unicode 排序元素表的副本。

Unicode 数据库

Unicode 标准提供了一个完整的数据库(许多结构化文本文件)​,不仅包括码点与字符名称之间的映射表,还包括各个字符的元数据,以及字符之间的关系。str 的 isalphaisprintableisdecimalisnumeric 等方法就是靠这些信息来判断的。str.casefold 方法也使用一个 Unicode 表中的信息。

unicodedata 模块中有几个函数用于获取字符的元数据。例如,unicodedata.name() 返回一个字符在标准中的官方名称:

1
2
3
4
>>> unicodedata.name('A')
'LATIN CAPITAL LETTER A'
>>> unicodedata.name('中')
'CJK UNIFIED IDEOGRAPH-4E2D'

unicodedata 模块中有几个函数可以检查 Unicode 字符是不是表示数值,如果是的话,还能确定人类可读的具体数值,而不是码点数。例如 unicodedata.numeric(char)

1
2
>>> unicodedata.numeric('六')
6.0

支持 str 和 bytes 的双模式 API

Python 标准库中的一些函数能接受 str 或 bytes 为参数,根据其具体类型展现不同的行为。re 和 os 模块中就有这样的函数。

  • 如果使用 bytes 构建正则表达式,则 \d 和 \w 等模式只能匹配 ASCII 字符;相比之下,如果是 str 模式,那就能匹配 ASCII 之外的 Unicode 数字或字母。使用 rb 来定义 bytes 类型的的正则表达式,使用 r 来定义 str 类型的正则表达式
  • 实际测试似乎中文数字无法匹配到
1
2
3
4
5
6
7
8
9
10
11
12
>>> import re

>>> re_numbers_str = re.compile(r'\d+')
>>> re_numbers_bytes = re.compile(rb'\d+')

>>> text = '六'
>>> bytes = text.encode('utf-8')

>>> text = '六'
>>> re_numbers_str = re.compile(r'\d+')
>>> re_numbers_str.findall(text)
[]
  • str 正则表达式有个 re.ASCII 标志,能让 \w、\W、\b、\B、\d、\D、\s\S 只匹配 ASCII 字符。详见 re 模块的文档

os 模块中所有接受文件名或路径名的函数,既可以传入 str 参数,也可以传入 bytes 参数:

  • 传入 str 参数时,使用 sys.getfilesystemencoding() 获得的编码解码器自动转换参数,操作系统回显时也使用该编码解码器解码
  • 对于无法使用上述方式自动处理的文件名,则可以把 bytes 参数传给 os 模块中的函数,得到 bytes 类型的返回值。如此一来,便可以处理任何文件名或路径名,不管里面有多少鬼符
1
2
3
4
5
>>> import os
>>> os.listdir('.')
['中文file', 'file1']
>>> os.listdir(b'.')
[b'\xe4\xb8\xad\xe6\x96\x87file', b'file1']

为了便于手动处理 str 或 bytes 类型的文件名或路径名,os 模块提供了特殊的编码解码函数 os.fsencode(name_or_path)os.fsdecode(name_or_path)

其他事项

说一点大家可能没有注意到的,Python3 允许在源码中使用非 ASCII 标识符:

1
2
3
>>> 中文="你好"
>>> 中文
'你好'

Python 官方文档对 str 的码点在内存中如何存储避而不谈。毕竟,这是实现细节。理论上,怎么存储都没关系,不管内部表述如何,输出时每个 str 都要编码成 bytes。在内存中,Python3 使用固定数量的字节存储 str 中的各个码点,以便高效访问任何字符或切片。从 Python3.3 起,创建 str 对象时,解释器会检查里面的字符,选择最经济的内存布局(选择能存储下该字符串所有字符的最短字节长度作为固定长度)。