<Fluent Python > Unicode 文本与字节

文章目录

  • [第四章:Unicode 文本与字节](#第四章:Unicode 文本与字节)
  • 代码示例
      • [示例 4-1:编码与解码基础](#示例 4-1:编码与解码基础)
      • [示例 4-2:`bytes` 与 `bytearray` 的基本操作](#示例 4-2:bytesbytearray 的基本操作)
      • [示例 4-3:从数组原始数据初始化 `bytes`](#示例 4-3:从数组原始数据初始化 bytes)
      • [示例 4-4:不同编码产生不同的字节序列](#示例 4-4:不同编码产生不同的字节序列)
      • [示例 4-5:编码错误处理(`UnicodeEncodeError`)](#示例 4-5:编码错误处理(UnicodeEncodeError))
      • [示例 4-6:解码错误处理(`UnicodeDecodeError`)](#示例 4-6:解码错误处理(UnicodeDecodeError))
      • [示例 4-7:源文件编码声明(`ola.py`)](#示例 4-7:源文件编码声明(ola.py))
      • [示例 4-8 与 4-9:文本文件读写与默认编码陷阱](#示例 4-8 与 4-9:文本文件读写与默认编码陷阱)
      • [示例 4-10:查看系统编码默认值](#示例 4-10:查看系统编码默认值)
      • [示例 4-11:Windows 控制台编码示例(需在 Windows 上运行)](#示例 4-11:Windows 控制台编码示例(需在 Windows 上运行))
      • [示例 4-12:标准输出重定向与编码错误](#示例 4-12:标准输出重定向与编码错误)
      • [示例 4-13:规范化比较函数(`normeq.py`)](#示例 4-13:规范化比较函数(normeq.py))
      • [示例 4-14:移除变音符号(`shave_marks`)](#示例 4-14:移除变音符号(shave_marks))
      • [示例 4-16:仅对拉丁字符移除变音符号](#示例 4-16:仅对拉丁字符移除变音符号)
      • [示例 4-17:转换为 ASCII 的激进函数(`asciize`)](#示例 4-17:转换为 ASCII 的激进函数(asciize))
      • [示例 4-19:使用 `locale.strxfrm` 排序](#示例 4-19:使用 locale.strxfrm 排序)
      • [示例 4-20:使用 `pyuca` 进行 Unicode 排序](#示例 4-20:使用 pyuca 进行 Unicode 排序)
      • [示例 4-21:字符查找工具 `cf.py`](#示例 4-21:字符查找工具 cf.py)
      • [示例 4-22:检查字符的数字属性](#示例 4-22:检查字符的数字属性)
      • [示例 4-23:正则表达式中 `str` 与 `bytes` 的差异](#示例 4-23:正则表达式中 strbytes 的差异)
      • [示例 4-24:`os.listdir` 的 `str` 与 `bytes` 模式](#示例 4-24:os.listdirstrbytes 模式)

第四章:Unicode 文本与字节

人类使用文本。计算机使用字节。

------Esther Nam 和 Travis Fischer,《Python 中的字符编码与 Unicode》

Python 3 对人类的文本字符串和原始的字节序列进行了严格的区分。将字节序列隐式转换为 Unicode 文本已成为过去。本章将讨论 Unicode 字符串、二进制序列以及用于它们之间转换的编码。

根据你使用 Python 所做的工作类型,你可能认为理解 Unicode 并不重要。这不太可能,但无论如何,strbytes 的分立是绕不开的。额外的好处是,你会发现专门的二进制序列类型提供了"万能"的 Python 2 str 类型所不具备的特性。

在本章中,我们将涉及以下主题:

  • 字符、码位和字节表示
  • 二进制序列的独特特性:bytesbytearraymemoryview
  • 完整 Unicode 和传统字符集的编码
  • 避免和处理编码错误
  • 处理文本文件的最佳实践
  • 默认编码陷阱与标准 I/O 问题
  • 通过规范化进行安全的 Unicode 文本比较
  • 规范化、大小写折叠和暴力移除变音符号的实用函数
  • 使用 localepyuca 库对 Unicode 文本进行正确排序
  • Unicode 数据库中的字符元数据
  • 同时处理 strbytes 的双模式 API

本章的新内容

Python 3 中对 Unicode 的支持已经全面且稳定,因此最显著的增加是第 151 页的"按名称查找字符",描述了一个用于搜索 Unicode 数据库的实用程序------这是一种在命令行中查找带圈数字和微笑猫的好方法。

另一个值得一提的小变化是 Windows 上的 Unicode 支持,自 Python 3.6 起变得更好更简单,我们将在第 134 页"注意编码默认值"中看到。

让我们从并不全新但基本的概念------字符、码位和字节开始。

第二版中,我扩展了关于 struct 模块的部分,并在配套网站 fluentpython.com 的"Parsing binary records with struct"中在线发布。

在那里你还可以找到"Building Multi-character Emojis",描述了如何通过组合 Unicode 字符来制作国旗、彩虹旗、不同肤色的人物以及多样化的家庭图标。

字符问题

"字符串"的概念足够简单:字符串是一个字符序列。问题在于"字符"的定义。

2021 年,我们对"字符"最好的定义是 Unicode 字符。因此,我们从 Python 3 str 中获取的项是 Unicode 字符,就像 Python 2 中 unicode 对象的项一样------而不是我们从 Python 2 str 中获得的原始字节。

Unicode 标准明确地将字符的身份与具体的字节表示分开:

  • 一个字符的身份------它的码位------是一个从 0 到 1,114,111(十进制)的数字,在 Unicode 标准中用 4 到 6 个十六进制数字表示,带有 "U+" 前缀,从 U+0000 到 U+10FFFF。例如,字母 A 的码位是 U+0041,欧元符号是 U+20AC,音乐符号 G 谱号分配给码位 U+1D11E。在 Python 3.10.0b4 中使用的 Unicode 13.0.0 标准中,大约 13% 的有效码位被分配了字符。
  • 表示一个字符的实际字节取决于所使用的编码 。编码是一种将码位转换为字节序列以及反向转换的算法。字母 A(U+0041)的码位在 UTF-8 编码中编码为单个字节 \x41,在 UTF-16LE 编码中编码为字节 \x41\x00。再如,UTF-8 需要三个字节 \xe2\x82\xac 来编码欧元符号(U+20AC),而在 UTF-16LE 中,同一个码位被编码为两个字节:\xac\x20

从码位转换为字节是编码 ;从字节转换为码位是解码。见示例 4-1。

示例 4-1. 编码与解码

python 复制代码
>>> s = 'café'
>>> len(s)                 # str 'café' 有四个 Unicode 字符
4
>>> b = s.encode('utf8')   # 使用 UTF-8 编码将 str 编码为 bytes
>>> b
b'caf\xc3\xa9'
>>> len(b)                 # bytes b 有五个字节("é"的码位在 UTF-8 中编码为两个字节)
5
>>> b.decode('utf8')       # 使用 UTF-8 编码将 bytes 解码为 str
'café'

如果你需要一个记忆辅助来区分 .decode().encode(),可以说字节序列是晦涩的机器核心转储,而 Unicode str 对象是"人类"文本。因此,我们将 bytes 解码str 以获得人类可读的文本,并将 str 编码bytes 以用于存储或传输,这是有道理的。

尽管 Python 3 的 str 基本上就是 Python 2 的 unicode 类型换了个新名字,但 Python 3 的 bytes 并不仅仅是旧的 str 重命名,还有密切相关的 bytearray 类型。因此,在深入编码/解码问题之前,我们有必要先看一下二进制序列类型。

字节基础

新的二进制序列类型在很多方面与 Python 2 的 str 不同。首先要知道的是,有两种基本的内置二进制序列类型:Python 3 中引入的不可变 bytes 类型和早在 Python 2.6 中就添加的可变 bytearray。Python 文档有时使用通用术语"字节字符串"来指代 bytesbytearray。我避免使用这个令人困惑的术语。

bytesbytearray 中的每个项都是 0 到 255 的整数,而不是像 Python 2 str 中那样的单字符字符串。然而,二进制序列的切片总是产生相同类型的二进制序列------包括长度为 1 的切片。见示例 4-2。

示例 4-2. 五个字节的序列作为 bytesbytearray

python 复制代码
>>> cafe = bytes('café', encoding='utf_8')  # bytes 可以从 str 构建,给定编码
>>> cafe
b'caf\xc3\xa9'
>>> cafe[0]                                 # 每个项是 range(256) 内的整数
99
>>> cafe[:1]                                # bytes 的切片仍然是 bytes------即使是单字节切片
b'c'
>>> cafe_arr = bytearray(cafe)
>>> cafe_arr
bytearray(b'caf\xc3\xa9')
>>> cafe_arr[-1:]
bytearray(b'\xa9')                          # bytearray 的切片也是 bytearray
  • 没有 bytearray 的字面量语法:它们显示为 bytearray(),以 bytes 字面量作为参数。

my_bytes[0] 获取一个 intmy_bytes[:1] 返回一个长度为 1 的 bytes 序列,这一事实之所以令人惊讶,只是因为我们习惯了 Python 的 str 类型,其中 s[0] == s[:1]。对于 Python 中的所有其他序列类型,1 个元素与长度为 1 的切片是不同的。

尽管二进制序列实际上是整数的序列,但它们的字面量表示反映了 ASCII 文本常常嵌入其中的事实。因此,根据每个字节的值,使用四种不同的显示方式:

  • 对于十进制码位 32 到 126 的字节------从空格到 ~(波浪号)------直接使用 ASCII 字符本身。
  • 对于对应制表符、换行符、回车符和 \ 的字节,使用转义序列 \t\n\r\\
  • 如果字符串分隔符 '" 都出现在字节序列中,则整个序列用 ' 分隔,并且内部的任何 ' 会被转义为 \'
  • 对于其他字节值,使用十六进制转义序列(例如,\x00 是空字节)。

这就是为什么在示例 4-2 中你看到 b'caf\xc3\xa9':前三个字节 b'caf' 在可打印的 ASCII 范围内,后两个不在。

bytesbytearray 都支持每个 str 方法,但那些进行格式化的方法(formatformat_map)以及那些依赖 Unicode 数据的方法(包括 casefoldisdecimalisidentifierisnumericisprintableencode)除外。这意味着你可以对二进制序列使用熟悉的字符串方法,如 endswithreplacestriptranslateupper 等------但只能使用 bytes 参数,不能使用 str 参数。此外,re 模块中的正则表达式函数也适用于二进制序列,如果正则表达式是从二进制序列而不是 str 编译的。自 Python 3.5 起,% 运算符再次适用于二进制序列。

二进制序列有一个 str 没有的类方法,叫做 fromhex,它通过解析可选的由空格隔开的十六进制数字对来构建二进制序列:

python 复制代码
>>> bytes.fromhex('31 4B CE A9')
b'1K\xce\xa9'

构建 bytesbytearray 实例的其他方式是调用它们的构造函数,传入:

  • 一个 str 和一个 encoding 关键字参数
  • 一个提供值为 0 到 255 的项的可迭代对象
  • 一个实现了缓冲协议的对象(例如 bytesbytearraymemoryviewarray.array),它会将源对象中的字节复制到新创建的二进制序列中

在 Python 3.5 之前,也可以使用单个整数调用 bytesbytearray 来创建一个用空字节初始化的大小的二进制序列。这种签名在 Python 3.5 中被弃用,并在 Python 3.6 中被移除。见 PEP 467------二进制序列的次要 API 改进。

从类似缓冲区的对象构建二进制序列是一个低级操作,可能涉及类型转换。见示例 4-3 的演示。

示例 4-3. 从数组的原始数据初始化 bytes

python 复制代码
>>> import array
>>> numbers = array.array('h', [-2, -1, 0, 1, 2])  # 类型码 'h' 创建一个短整数数组(16位)
>>> octets = bytes(numbers)                       # octets 保存组成 numbers 的字节的副本
>>> octets
b'\xfe\xff\xff\xff\x00\x00\x01\x00\x02\x00'       # 这是表示 5 个短整数的 10 个字节

从任何类似缓冲区的源创建 bytesbytearray 对象总是会复制字节。相比之下,memoryview 对象允许在二进制数据结构之间共享内存,正如我们在第 62 页"内存视图"中看到的。

在初步探索了 Python 中的二进制序列类型之后,让我们看看它们如何与字符串相互转换。

基本编码器/解码器

Python 发行版捆绑了 100 多种编解码器(编码器/解码器),用于文本与字节的相互转换。每个编解码器都有一个名称,如 'utf_8',并且通常有别名,如 'utf8''utf-8''U8',你可以在诸如 open()str.encode()bytes.decode() 等函数中将其用作 encoding 参数。示例 4-4 展示了相同的文本被编码为三种不同的字节序列。

示例 4-4. 字符串"El Niño"使用三种编解码器编码生成非常不同的字节序列

python 复制代码
>>> for codec in ['latin_1', 'utf_8', 'utf_16']:
...     print(codec, 'El Niño'.encode(codec), sep='\t')
...
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'

图 4-1 展示了各种编解码器如何从字符(如字母 "A" 到 G 谱号音乐符号)生成字节。请注意,最后三种编码是变长的、多字节的编码。

图 4-1 中所有的星号清楚地表明,某些编码(如 ASCII,甚至是多字节的 GB2312)无法表示每个 Unicode 字符。然而,UTF 编码被设计为能够处理每个 Unicode 码位。

图 4-1 中显示的编码是作为代表性样本选择的:

  • latin1 又名 iso8859_1

    重要,因为它是其他编码(如 cp1252 和 Unicode 本身)的基础(注意 latin1 字节值如何出现在 cp1252 字节甚至码位中)。

  • cp1252

    一个由 Microsoft 创建的有用的 latin1 超集,添加了有用的符号,如弯引号和 €(欧元);一些 Windows 应用程序称之为 "ANSI",但它从未是真正的 ANSI 标准。

  • cp437

    IBM PC 的原始字符集,带有制表符绘制字符。与后来出现的 latin1 不兼容。

  • gb2312

    用于编码中国大陆使用的简体汉字的传统标准;几种广泛部署的亚洲语言多字节编码之一。

  • utf-8

    截至 2021 年 7 月,网络上最常见的 8 位编码,遥遥领先。根据 "W3Techs: Usage statistics of character encodings for websites",97% 的网站使用 UTF-8,而在 2014 年 9 月我写本书第一版这段文字时为 81.4%。

  • utf-16le

    UTF 16 位编码方案的一种形式;所有 UTF-16 编码都通过称为"代理对"的转义序列支持 U+FFFF 之外的码位。

UTF-16 早在 1996 年就取代了原始的 16 位 Unicode 1.0 编码------UCS-2。尽管自上世纪以来就已弃用,但 UCS-2 仍在许多系统中使用,因为它只支持到 U+FFFF 的码位。截至 2021 年,超过 57% 的已分配码位在 U+FFFF 之上,包括非常重要的表情符号。

在完成对常见编码的概述之后,我们将转向处理编码和解码操作中的问题。

理解编码/解码问题

尽管有一个通用的 UnicodeError 异常,但 Python 报告的错误通常更具体:要么是 UnicodeEncodeError(当将 str 转换为二进制序列时),要么是 UnicodeDecodeError(当将二进制序列读入 str 时)。当源编码不符合预期时,加载 Python 模块也可能引发 SyntaxError。我们将在接下来的部分中展示如何处理所有这些错误。

当你遇到 Unicode 错误时,首先要弄清楚的是异常的确切类型。是 UnicodeEncodeErrorUnicodeDecodeError,还是提到编码问题的其他错误(如 SyntaxError)?要解决问题,你必须首先理解它。

应对 UnicodeEncodeError

大多数非 UTF 编解码器只处理 Unicode 字符的一个小子集。在将文本转换为字节时,如果目标编码中未定义某个字符,则会引发 UnicodeEncodeError,除非通过向编码方法或函数传递 errors 参数来提供特殊处理。示例 4-5 展示了错误处理程序的行为。

示例 4-5. 编码为字节:成功与错误处理

python 复制代码
>>> city = 'São Paulo'
>>> city.encode('utf_8')          # UTF 编码处理任何 str
b'S\xc3\xa3o Paulo'
>>> city.encode('utf_16')
b'\xff\xfeS\x00\xe3\x00o\x00 \x00P\x00a\x00u\x00l\x00o\x00'
>>> city.encode('iso8859_1')      # iso8859_1 也能处理 'São Paulo'
b'S\xe3o Paulo'
>>> city.encode('cp437')          # cp437 无法编码 'ã'(带波浪号的 a)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/.../lib/python3.4/encodings/cp437.py", line 12, in encode
    return codecs.charmap_encode(input,errors,encoding_map)
UnicodeEncodeError: 'charmap' codec can't encode character '\xe3' in position 1: character maps to <undefined>
>>> city.encode('cp437', errors='ignore')      # 跳过无法编码的字符(通常很糟糕)
b'So Paulo'
>>> city.encode('cp437', errors='replace')    # 用 '?' 替换;数据丢失但有提示
b'S?o Paulo'
>>> city.encode('cp437', errors='xmlcharrefreplace')  # 替换为 XML 实体
b'S&#227;o Paulo'
  • 默认的错误处理程序 'strict' 引发 UnicodeEncodeError
  • error='ignore' 处理程序跳过无法编码的字符;这通常是一个非常糟糕的主意,会导致数据静默丢失。
  • 编码时,error='replace' 将无法编码的字符替换为 '?';数据也会丢失,但用户会得到一些线索。
  • 'xmlcharrefreplace' 将无法编码的字符替换为 XML 实体。如果你不能使用 UTF,又不能承受数据丢失,这是唯一的选择。

编解码器的错误处理是可扩展的。你可以通过向 codecs.register_error 函数传递一个名称和一个错误处理函数来为 errors 参数注册额外的字符串。参见 codecs.register_error 的文档。

ASCII 是我所知道的所有编码的一个常见子集,因此如果文本完全由 ASCII 字符组成,编码应该总是能成功。Python 3.7 添加了一个新的布尔方法 str.isascii() 来检查你的 Unicode 文本是否为 100% 纯 ASCII。如果是,你应该能够在任何编码中将其编码为字节而不会引发 UnicodeEncodeError

应对 UnicodeDecodeError

并非每个字节都包含有效的 ASCII 字符,也并非每个字节序列都是有效的 UTF-8 或 UTF-16;因此,当你在将二进制序列转换为文本时假定其中一种编码,如果遇到意外的字节,就会得到 UnicodeDecodeError

另一方面,许多传统的 8 位编码如 'cp1252''iso8859_1''koi8_r' 能够解码任何字节流(包括随机噪声)而不报告错误。因此,如果你的程序假定了错误的 8 位编码,它将静默地解码出垃圾数据。

乱码字符被称为 gremlins 或 mojibake(文字化け------日语意为"变形文本")。

示例 4-6 说明了使用错误的编解码器如何产生乱码或 UnicodeDecodeError

示例 4-6. 从 str 解码为 bytes:成功与错误处理

python 复制代码
>>> octets = b'Montr\xe9al'                     # "Montréal" 用 latin1 编码;'\xe9' 是 "é" 的字节
>>> octets.decode('cp1252')                     # 用 Windows 1252 解码有效,因为它是 latin1 的超集
'Montréal'
>>> octets.decode('iso8859_7')                  # ISO-8859-7 用于希腊语,误解了 '\xe9',但没有报错
'Montrιal'
>>> octets.decode('koi8_r')                     # KOI8-R 用于俄语;'\xe9' 现在代表西里尔字母 "И"
'MontrИal'
>>> octets.decode('utf_8')                      # 'utf_8' 编解码器检测到 octets 不是有效的 UTF-8
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 5: invalid continuation byte
>>> octets.decode('utf_8', errors='replace')    # 使用 'replace' 错误处理,\xe9 被替换为 "�"
'Montr�al'
  • 使用 'replace' 错误处理,\xe9 被替换为 "�"(码位 U+FFFD),即官方的 Unicode 替换字符,用于表示未知字符。

加载带有意外编码的模块时的 SyntaxError

UTF-8 是 Python 3 的默认源编码,就像 ASCII 是 Python 2 的默认编码一样。如果你加载一个包含非 UTF-8 数据且没有编码声明的 .py 模块,你会收到类似这样的消息:

复制代码
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

由于 UTF-8 在 GNU/Linux 和 macOS 系统中被广泛部署,一个可能的情况是打开一个在 Windows 上用 cp1252 创建的 .py 文件。注意,这个错误甚至在 Windows 上的 Python 中也会发生,因为 Python 3 源代码在所有平台上的默认编码都是 UTF-8。

要解决这个问题,在文件顶部添加一个魔术编码注释,如示例 4-7 所示。

示例 4-7. ola.py:葡萄牙语的"Hello, World!"

python 复制代码
# coding: cp1252
print('Olá, Mundo!')

既然 Python 3 源代码不再限于 ASCII,并且默认使用优秀的 UTF-8 编码,对于像 'cp1252' 这样的传统编码中的源代码,最好的"修复"方法就是将它们转换为 UTF-8,而不必理会编码注释。如果你的编辑器不支持 UTF-8,是时候换一个了。

假设你有一个文本文件,无论是源代码还是诗歌,但你不知道它的编码。如何检测实际的编码?答案在下一节。

如何发现字节序列的编码

你如何找出字节序列的编码?简短的回答是:你无法知道。你必须被告知。

一些通信协议和文件格式(如 HTTP 和 XML)包含显式告诉我们内容如何编码的头部。你可以确信某些字节流不是 ASCII,因为它们包含超过 127 的字节值,而且 UTF-8 和 UTF-16 的构建方式也限制了可能的字节序列。

Leo 关于猜测 UTF-8 解码的技巧

(以下段落来自技术审阅者 Leonardo Rochael 在本书草稿中留下的笔记。)

UTF-8 的设计方式使得随机字节序列,甚至来自非 UTF-8 编码的非随机字节序列,几乎不可能被意外地解码为 UTF-8 中的垃圾,而不是引发 UnicodeDecodeError

原因是 UTF-8 的转义序列从不使用 ASCII 字符,而且这些转义序列的位模式使得随机数据很难偶然成为有效的 UTF-8。

因此,如果你能解码一些包含大于 127 的码位的字节作为 UTF-8,那它很可能是 UTF-8。

在处理巴西的在线服务时(其中一些依附于传统的后端),我有时不得不实施一种解码策略:先尝试通过 UTF-8 解码,如果遇到 UnicodeDecodeError,再通过 cp1252 解码。这很丑陋但很有效。

然而,考虑到人类语言也有自己的规则和限制,一旦你假定一个字节流是人类纯文本,就有可能使用启发式和统计来嗅探出其编码。例如,如果 b'\x00' 字节很常见,它很可能是 16 位或 32 位编码,而不是 8 位方案,因为纯文本中的空字符串是错误。当字节序列 b'\x20\x00' 经常出现时,它更可能是 UTF-16LE 编码中的空格字符(U+0020),而不是晦涩的 U+2000 EN QUAD 字符------不管那是什么。

这就是 "Chardet------通用字符编码检测器" 包的工作原理,用于猜测 30 多种支持的编码中的一种。Chardet 是一个 Python 库,你可以在你的程序中使用它,但它也包含一个命令行实用程序 chardetect。以下是它在本章源文件上报告的结果:

bash 复制代码
$ chardetect 04-text-byte.asciidoc
04-text-byte.asciidoc: utf-8 with confidence 0.99

尽管编码文本的二进制序列通常不携带其编码的显式提示,但 UTF 格式可能会在文本内容前加上一个字节顺序标记。接下来将解释这一点。

BOM:一个有用的 gremlin

在示例 4-4 中,你可能注意到 UTF-16 编码序列开头有几个额外的字节。这里再次显示:

python 复制代码
>>> u16 = 'El Niño'.encode('utf_16')
>>> u16
b'\xff\xfeE\x00l\x00 \x00N\x00i\x00\xf1\x00o\x00'

字节是 b'\xff\xfe'。这是一个 BOM------字节顺序标记------表示执行编码的 Intel CPU 的"小端"字节顺序。

在小端机器上,对于每个码位,最低有效字节在前:字母 'E',码位 U+0045(十进制 69),在字节偏移量 2 和 3 处被编码为 69 和 0:

python 复制代码
>>> list(u16)
[255, 254, 69, 0, 108, 0, 32, 0, 78, 0, 105, 0, 241, 0, 111, 0]

在大端 CPU 上,编码将是相反的;'E' 将被编码为 0 和 69。

为了避免混淆,UTF-16 编码在要编码的文本前加上特殊的不可见字符零宽度不换行空格 (U+FEFF)。在小端系统上,它被编码为 b'\xff\xfe'(十进制 255, 254)。因为按照设计,Unicode 中没有 U+FFFE 字符,所以字节序列 b'\xff\xfe' 必定意味着小端编码中的零宽度不换行空格,因此编解码器知道使用哪种字节顺序。

有一个 UTF-16 的变体------UTF-16LE------是显式的小端,另一个是显式的大端 UTF-16BE。如果你使用它们,则不会生成 BOM:

python 复制代码
>>> u16le = 'El Niño'.encode('utf_16le')
>>> list(u16le)
[69, 0, 108, 0, 32, 0, 78, 0, 105, 0, 241, 0, 111, 0]
>>> u16be = 'El Niño'.encode('utf_16be')
>>> list(u16be)
[0, 69, 0, 108, 0, 32, 0, 78, 0, 105, 0, 241, 0, 111]

如果存在 BOM,它应该被 UTF-16 编解码器过滤掉,这样你只能获得文件的实际文本内容,而没有前导的零宽度不换行空格。Unicode 标准规定,如果一个文件是 UTF-16 且没有 BOM,则应假定为 UTF-16BE(大端)。然而,Intel x86 架构是小端的,因此在实际中大量存在没有 BOM 的小端 UTF-16。

整个端序问题只影响使用多字节字的编码,如 UTF-16 和 UTF-32。UTF-8 的一个巨大优势是它无论机器端序如何都产生相同的字节序列,因此不需要 BOM。尽管如此,一些 Windows 应用程序(尤其是 Notepad)仍然向 UTF-8 文件添加 BOM------而 Excel 依赖 BOM 来检测 UTF-8 文件,否则它会假定内容是用 Windows 代码页编码的。这种带 BOM 的 UTF-8 编码在 Python 的编解码器注册表中称为 UTF-8-SIG。用 UTF-8-SIG 编码的字符 U+FEFF 是三字节序列 b'\xef\xbb\xbf'。因此,如果一个文件以这三个字节开头,它很可能是带 BOM 的 UTF-8 文件。

Caleb 关于 UTF-8-SIG 的技巧

技术审阅者 Caleb Hattingh 建议在读取 UTF-8 文件时始终使用 UTF-8-SIG 编解码器。这是无害的,因为 UTF-8-SIG 能正确处理带或不带 BOM 的文件,并且不会返回 BOM 本身。写入时,为了通用互操作性,我建议使用普通的 UTF-8。例如,Python 脚本如果以注释 #!/usr/bin/env python3 开头,就可以在 Unix 系统上可执行。文件的前两个字节必须是 b'#!' 才能工作,但 BOM 会破坏这个约定。如果你有特定需求要将数据导出到需要 BOM 的应用程序,可以使用 UTF-8-SIG,但请注意 Python 的 codecs 文档说:"在 UTF-8 中,不鼓励使用 BOM,通常应该避免。"

现在我们将继续讨论在 Python 3 中处理文本文件。

处理文本文件

处理文本 I/O 的最佳实践是 "Unicode 三明治" (图 4-2)。这意味着在输入时应尽早将 bytes 解码为 str(例如,在打开文件读取时)。"三明治"的馅料是你的程序的业务逻辑,其中文本处理完全在 str 对象上进行。你绝不应该在其他处理的中间进行编码或解码。在输出时,应尽可能晚地将 str 编码为 bytes。大多数 Web 框架都是这样工作的,我们在使用它们时几乎不接触 bytes。例如,在 Django 中,你的视图应该输出 Unicode str;Django 本身负责使用 UTF-8 默认将响应编码为 bytes

Python 3 使得遵循 Unicode 三明治的建议更加容易,因为内置函数 open() 在文本模式下读取时进行必要的解码,在写入时进行编码,因此你从 my_file.read() 获得并传递给 my_file.write(text) 的都是 str 对象。

因此,使用文本文件显然很简单。但如果你依赖默认编码,就会遇到问题。

考虑示例 4-8 中的控制台会话。你发现 bug 了吗?

示例 4-8. 一个平台编码问题(如果你在自己的机器上尝试,可能会也可能不会看到问题)

python 复制代码
>>> open('cafe.txt', 'w', encoding='utf_8').write('café')
4
>>> open('cafe.txt').read()
'café'
  • bug:我在写入文件时指定了 UTF-8 编码,但读取时没有指定,因此 Python 假定了 Windows 默认的文件编码------代码页 1252------文件中的尾随字节被解码为字符 'é' 而不是 'é'

我在 Windows 10(内部版本 18363)上的 Python 3.8.1 64 位版本运行了示例 4-8。在最近的 GNU/Linux 或 macOS 上运行相同的语句则完美工作,因为它们的默认编码是 UTF-8,这给人一种一切正常的假象。如果在写入文件时省略了 encoding 参数,则会使用区域设置的默认编码,然后使用相同的编码读取文件会正确。但那样的话,该脚本将根据平台甚至同一平台上区域设置的差异产生不同字节内容的文件,从而产生兼容性问题。

必须在多台机器上或多次运行的代码绝不应该 依赖编码默认值。在打开文本文件时始终传递显式的 encoding= 参数,因为默认值可能因机器而异,或随时间变化。

示例 4-8 中的一个有趣细节是,第一条语句中的 write 函数报告写入了四个字符,但下一行读取了五个字符。示例 4-9 是示例 4-8 的扩展版本,解释了这一点和其他细节。

示例 4-9. 在 Windows 上仔细检查示例 4-8,揭示 bug 及其修复方法

python 复制代码
>>> 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 将 'é' 编码为 2 个字节,0xc30xa9
  • 打开一个没有显式编码的文本文件,返回一个 TextIOWrapper,其编码设置为来自区域设置的默认值。
  • TextIOWrapper 对象有一个 encoding 属性,你可以检查它:在这个例子中是 cp1252
  • 在 Windows cp1252 编码中,字节 0xc3"Ã"(带波浪号的 A),而 0xa9 是版权符号。
  • 使用正确编码打开同一个文件。
  • 预期的结果:与 'café' 相同的四个 Unicode 字符。
  • 'rb' 标志以二进制模式打开文件进行读取。
  • 返回的对象是 BufferedReader 而不是 TextIOWrapper。读取返回 bytes,符合预期。

除非你需要分析文件内容以确定编码------即使那样,你也应该使用 Chardet 而不是重新发明轮子(见第 128 页"如何发现字节序列的编码")------否则不要以二进制模式打开文本文件。普通代码只应以二进制模式打开二进制文件,如光栅图像。

示例 4-9 中的问题与打开文本文件时依赖默认设置有关。此类默认值有多个来源,如下一节所示。

注意编码默认值

有几个设置会影响 Python 中 I/O 的编码默认值。参见示例 4-10 中的 default_encodings.py 脚本。

示例 4-10. 探索编码默认值

python 复制代码
import locale
import sys

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(f'{expression:>30} -> {value!r}')

示例 4-10 在 GNU/Linux(Ubuntu 14.04 到 19.10)和 macOS(10.9 到 10.14)上的输出是相同的,显示 UTF-8 在这些系统中无处不在:

bash 复制代码
$ 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-11 所示。

示例 4-11. Windows 10 PowerShell 上的默认编码(在 cmd.exe 上输出相同)

bash 复制代码
> chcp
Active code page: 437
> python default_encodings.py
     locale.getpreferredencoding() -> 'cp1252'
                    type(my_file) -> <class '_io.TextIOWrapper'>
                  my_file.encoding -> 'cp1252'
               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'
  • chcp 显示控制台的活动代码页:437。
  • 运行 default_encodings.py 并将输出打印到控制台。
  • locale.getpreferredencoding() 是最重要的设置。
  • 文本文件默认使用 locale.getpreferredencoding()
  • 输出将进入控制台,因此 sys.stdout.isatty()True
  • 现在,sys.stdout.encodingchcp 报告的控制台代码页不同!

自从我写完本书第一版以来,Windows 本身以及 Python for Windows 中的 Unicode 支持都有了改进。示例 4-11 在 Windows 7 上的 Python 3.4 中曾经报告四种不同的编码。stdoutstdinstderr 的编码曾经与 chcp 命令报告的活动代码页相同,但现在由于 PEP 528------将 Windows 控制台编码更改为 UTF-8(在 Python 3.6 中实现)以及 cmd.exe 中 PowerShell 的 Unicode 支持(自 2018 年 10 月的 Windows 1809 起),它们都是 utf-8。当 stdout 写入控制台时,chcpsys.stdout.encoding 说不同的事情很奇怪,但很棒的是,现在我们可以打印 Unicode 字符串而不会在 Windows 上出现编码错误------除非用户将输出重定向到文件,我们很快就会看到。这并不意味着你所有喜欢的表情符号都会出现在控制台中:这也取决于控制台使用的字体。

另一个变化是 PEP 529------将 Windows 文件系统编码更改为 UTF-8,也在 Python 3.6 中实现,将文件系统编码(用于表示目录和文件的名称)从 Microsoft 专有的 MBCS 更改为 UTF-8。

然而,如果示例 4-10 的输出被重定向到文件,像这样:

复制代码
Z:\>python default_encodings.py > encodings.log

那么,sys.stdout.isatty() 的值变为 False,并且 sys.stdout.encodinglocale.getpreferredencoding() 设置,在那台机器上是 'cp1252'------但 sys.stdin.encodingsys.stderr.encoding 仍然是 utf-8

在示例 4-12 中,我使用了 Unicode 字面量的 '\N{}' 转义,其中我们在 \N{} 内写入字符的官方名称。这相当冗长,但明确且安全:如果名称不存在,Python 会引发 SyntaxError------这比写一个可能是错误的十六进制数字要好得多,后者你可能要到很久以后才会发现。你可能还是想写注释来解释字符代码,因此 \N{} 的冗长很容易接受。

这意味着像示例 4-12 这样的脚本在打印到控制台时有效,但当输出重定向到文件时可能会出错。

示例 4-12. stdout_check.py

python 复制代码
import sys
from unicodedata import name

print(sys.version)
print()
print('sys.stdout.isatty():', sys.stdout.isatty())
print('sys.stdout.encoding:', sys.stdout.encoding)
print()

test_chars = [
    '\N{HORIZONTAL ELLIPSIS}',   # 存在于 cp1252,不存在于 cp437
    '\N{INFINITY}',              # 存在于 cp437,不存在于 cp1252
    '\N{CIRCLED NUMBER FORTY TWO}',  # 既不在 cp437 也不在 cp1252
]

for char in test_chars:
    print(f'Trying to output {name(char)}:')
    print(char)

示例 4-12 显示 sys.stdout.isatty() 的结果、sys.stdout.encoding 的值以及这三个字符:

  • '...' 水平省略号------存在于 CP 1252 但不存在于 CP 437。
  • '∞' 无穷------存在于 CP 437 但不存在于 CP 1252。
  • '㊷' 带圈数字四十二------既不存在于 CP 1252 也不存在于 CP 437。

当我在 PowerShell 或 cmd.exe 上运行 stdout_check.py 时,它如图 4-3 所示工作。

尽管 chcp 报告活动代码为 437,但 sys.stdout.encoding 是 UTF-8,因此水平省略号和无穷都能正确输出。带圈数字四十二被替换为一个矩形,但没有引发错误。推测它被识别为一个有效字符,但控制台字体没有显示它的字形。

然而,当我将 stdout_check.py 的输出重定向到一个文件时,我得到了图 4-4。

图 4-4 展示的第一个问题是 UnicodeEncodeError 提到字符 '\u221e',因为 sys.stdout.encoding'cp1252'------一个没有无穷字符的代码页。

使用 type 命令------或像 VS Code 或 Sublime Text 这样的 Windows 编辑器------读取 out.txt,显示我没有得到水平省略号,而是得到了 'à'(带重音符的拉丁小写字母 A)。事实证明,CP 1252 中的字节值 0x85 表示 '...',但在 CP 437 中,相同的字节值表示 'à'。因此,活动代码页似乎确实有影响,但不是以明智或有用的方式,而只是作为糟糕 Unicode 体验的部分解释。

我使用了一台配置为美国市场的笔记本电脑,运行 Windows 10 OEM 来运行这些实验。本地化为其他国家的 Windows 版本可能有不同的编码配置。例如,在巴西,Windows 控制台默认使用代码页 850------而不是 437。

为了结束这个令人抓狂的默认编码问题,让我们最后看一下示例 4-11 中的不同编码:

  • 如果你在打开文件时省略 encoding 参数,默认值由 locale.getpreferredencoding() 给出(在示例 4-11 中是 'cp1252')。
  • 在 Python 3.6 之前,sys.stdout|stdin|stderr 的编码由环境变量 PYTHONIOENCODING 设置------现在该变量被忽略,除非 PYTHONLEGACYWINDOWSSTDIO 被设置为非空字符串。否则,标准 I/O 的编码对于交互式 I/O 是 UTF-8,如果输出/输入被重定向到/从文件,则由 locale.getpreferredencoding() 定义。
  • sys.getdefaultencoding() 由 Python 内部用于二进制数据与 str 的隐式转换。不支持更改此设置。
  • sys.getfilesystemencoding() 用于编码/解码文件名(不是文件内容)。当 open() 接收到一个 str 参数作为文件名时使用;如果文件名作为 bytes 参数给出,则不加改变地传递给 OS API。

在 GNU/Linux 和 macOS 上,所有这些编码默认都设置为 UTF-8,并且已经持续了好几年,因此 I/O 能处理所有 Unicode 字符。而在 Windows 上,不仅在同一系统中使用不同的编码,而且它们通常是像 'cp850''cp1252' 这样的代码页,只支持 ASCII,外加 127 个额外字符,但这些字符在不同的编码中并不相同。因此,除非用户格外小心,否则 Windows 用户更可能面临编码错误。

总之,最重要的编码设置是 locale.getpreferredencoding() 返回的那个:它是打开文本文件以及当 sys.stdout/stdin/stderr 被重定向到文件时的默认编码。然而,文档部分写道:

locale.getpreferredencoding(do_setlocale=True)

根据用户偏好返回用于文本数据的编码。用户偏好在不同系统上的表达方式不同,在某些系统上可能无法通过编程获得,因此此函数只返回一个猜测。[...]

因此,关于编码默认值的最佳建议是:不要依赖它们

如果你遵循 Unicode 三明治的建议,并且在程序中始终明确指定编码,你将避免很多痛苦。不幸的是,即使你正确地将字节转换为 str,Unicode 仍然令人痛苦。接下来的两节将介绍在 ASCII 世界中简单,但在 Unicode 星球上变得相当复杂的主题:文本规范化(即将文本转换为统一表示以便比较)和排序。

规范化 Unicode 以实现可靠比较

字符串比较因 Unicode 具有组合字符而复杂化:变音符号和其他附加到前一个字符的标记,在打印时看起来像一个字符。

例如,单词 "café" 可以用两种方式组成,使用四个或五个码位,但结果看起来完全相同:

python 复制代码
>>> s1 = 'café'
>>> s2 = 'cafe\N{COMBINING ACUTE ACCENT}'
>>> s1, s2
('café', 'café')
>>> len(s1), len(s2)
(4, 5)
>>> s1 == s2
False

将组合锐音符(U+0301)放在 "e" 之后呈现为 "é"。在 Unicode 标准中,像 'é''e\u0301' 这样的序列被称为"规范等价",应用程序应该将它们视为相同。但 Python 看到两个不同的码位序列,认为它们不相等。

解决方案是 unicodedata.normalize()。该函数的第一个参数是四个字符串之一:'NFC''NFD''NFKC''NFKD'。让我们从前两个开始。

规范化形式 C (NFC)组合码位以产生最短的等价字符串,而 NFD 分解,将组合字符扩展为基本字符和单独的组合字符。这两种规范化都能使比较如预期工作,如下一个示例所示:

python 复制代码
>>> from unicodedata import normalize
>>> s1 = 'café'
>>> s2 = 'cafe\N{COMBINING ACUTE ACCENT}'
>>> len(s1), len(s2)
(4, 5)
>>> len(normalize('NFC', s1)), len(normalize('NFC', s2))
(4, 4)
>>> len(normalize('NFD', s1)), len(normalize('NFD', s2))
(5, 5)
>>> normalize('NFC', s1) == normalize('NFC', s2)
True
>>> normalize('NFD', s1) == normalize('NFD', s2)
True

键盘驱动程序通常生成组合字符,因此用户输入的文本默认将是 NFC。然而,为了安全起见,在保存之前用 normalize('NFC', user_text) 规范化字符串可能是好的。NFC 也是 W3C 在"Character Model for the World Wide Web: String Matching and Searching"中推荐的规范化形式。

有些单个字符被 NFC 规范化为另一个单个字符。电阻单位欧姆(Ω)的符号被规范化为希腊大写字母欧米茄。它们在视觉上相同,但比较时不相等,因此规范化对于避免意外至关重要:

python 复制代码
>>> from unicodedata import normalize, name
>>> ohm = '\u2126'
>>> name(ohm)
'OHM SIGN'
>>> ohm_c = normalize('NFC', ohm)
>>> name(ohm_c)
'GREEK CAPITAL LETTER OMEGA'
>>> ohm == ohm_c
False
>>> normalize('NFC', ohm) == normalize('NFC', ohm_c)
True

另外两种规范化形式是 NFKCNFKD,其中字母 K 代表"兼容性"。这些是更强形式的规范化,影响所谓的"兼容字符"。尽管 Unicode 的一个目标是为每个字符提供一个"规范"的码位,但有些字符为了与已有标准兼容而出现多次。例如,微符号 µ(U+00B5)被添加到 Unicode 中以支持与包含它的 latin1 进行往返转换,尽管同一个字符是希腊字母表的一部分,码位为 U+03BC(希腊小写字母 mu)。因此,微符号被认为是一个"兼容字符"。

在 NFKC 和 NFKD 形式中,每个兼容字符被替换为"兼容分解",由一个或多个被认为是"首选"表示的字符组成,即使会丢失一些格式信息------理想情况下,格式应该是外部标记的责任,而不是 Unicode 的一部分。举例来说,二分之一分数 '½'(U+00BD)的兼容分解是三个字符的序列 '1/2',微符号 'µ'(U+00B5)的兼容分解是小写 mu 'μ'(U+03BC)。

以下是 NFKC 在实际中的工作方式:

python 复制代码
>>> from unicodedata import normalize, name
>>> half = '\N{VULGAR FRACTION ONE HALF}'
>>> print(half)
½
>>> normalize('NFKC', half)
'1⁄2'
>>> for char in normalize('NFKC', half):
...     print(char, name(char), sep='\t')
...
1   DIGIT ONE
⁄   FRACTION SLASH
2   DIGIT TWO
>>> four_squared = '4²'
>>> normalize('NFKC', four_squared)
'42'
>>> micro = 'µ'
>>> micro_kc = normalize('NFKC', micro)
>>> micro, micro_kc
('µ', 'μ')
>>> ord(micro), ord(micro_kc)
(181, 956)
>>> name(micro), name(micro_kc)
('MICRO SIGN', 'GREEK SMALL LETTER MU')

尽管 '1⁄2''½' 的合理替代品,并且微符号实际上就是希腊小写 mu,但将 '4²' 转换为 '42' 改变了含义。一个应用程序可能将 '4²' 存储为 '4<sup>2</sup>',但 normalize 函数对格式化一无所知。因此,NFKC 或 NFKD 可能会丢失或扭曲信息,但它们可以为搜索和索引提供方便的中间表示。

不幸的是,对于 Unicode,一切都比最初看起来更复杂。对于二分之一分数,NFKC 规范化产生了由分数斜线(FRACTION SLASH)连接的 1 和 2,而不是 SOLIDUS(即通常的"斜杠"------ASCII 码为 47 的熟悉字符)。因此,搜索三个字符的 ASCII 序列 '1/2' 不会找到规范化的 Unicode 序列。

NFKC 和 NFKD 规范化会导致数据丢失,应仅在搜索和索引等特殊情况下应用,而不应用于文本的永久存储。

在准备搜索或索引文本时,另一个有用的操作是大小写折叠,这是我们下一个主题。

大小写折叠

大小写折叠本质上是将所有文本转换为小写,并带有一些额外的转换。它由 str.casefold() 方法支持。

对于任何只包含 latin1 字符的字符串 ss.casefold() 产生与 s.lower() 相同的结果,只有两个例外------微符号 'µ' 变为希腊小写 mu(在大多数字体中看起来相同),以及德语 Eszett 或 "sharp s"(ß)变为 "ss"

python 复制代码
>>> micro = 'µ'
>>> name(micro)
'MICRO SIGN'
>>> micro_cf = micro.casefold()
>>> name(micro_cf)
'GREEK SMALL LETTER MU'
>>> micro, micro_cf
('µ', 'μ')
>>> eszett = 'ß'
>>> name(eszett)
'LATIN SMALL LETTER SHARP S'
>>> eszett_cf = eszett.casefold()
>>> eszett, eszett_cf
('ß', 'ss')

有近 300 个码位,str.casefold()str.lower() 返回不同的结果。

与 Unicode 相关的任何事情一样,大小写折叠是一个棘手的问题,有大量的语言特殊情况,但 Python 核心团队努力提供了一个希望对大多数用户有效的解决方案。

在接下来的两节中,我们将运用我们的规范化知识来开发实用函数。

规范化文本匹配的实用函数

正如我们所见,NFC 和 NFD 使用安全,并允许在 Unicode 字符串之间进行合理的比较。NFC 是大多数应用程序的最佳规范化形式。str.casefold() 是不区分大小写比较的最佳方式。

如果你处理多种语言的文本,示例 4-13 中的一对函数如 nfc_equalfold_equal 将是你工具箱中有用的补充。

示例 4-13. normeq.py:规范化的 Unicode 字符串比较

python 复制代码
"""
规范化 Unicode 字符串比较的实用函数。

使用规范形式 C,大小写敏感:
>>> s1 = 'café'
>>> s2 = 'cafe\u0301'
>>> s1 == s2
False
>>> nfc_equal(s1, s2)
True
>>> nfc_equal('A', 'a')
False

使用规范形式 C 及大小写折叠:
>>> s3 = 'Straße'
>>> s4 = 'strasse'
>>> s3 == s4
False
>>> nfc_equal(s3, s4)
False
>>> fold_equal(s3, s4)
True
>>> fold_equal(s1, s2)
True
>>> fold_equal('A', 'a')
True
"""

from unicodedata import normalize

def nfc_equal(str1, str2):
    return normalize('NFC', str1) == normalize('NFC', str2)

def fold_equal(str1, str2):
    return (normalize('NFC', str1).casefold() ==
            normalize('NFC', str2).casefold())

除了 Unicode 规范化和大小写折叠------两者都是 Unicode 标准的一部分------有时进行更深层次的转换是有意义的,比如将 'café' 改为 'cafe'。我们将在下一节中看到何时以及如何做到这一点。

极端"规范化":移除变音符号

Google 搜索的秘密配方涉及许多技巧,但其中一个显然是在某些上下文中忽略变音符号(如重音、软音符等)。移除变音符号并不是一种正确的规范化形式,因为它常常改变单词的含义,并且在搜索时可能产生误报。但它有助于处理一些现实情况:人们有时对于变音符号的正确使用是懒惰或无知的,并且拼写规则随时间变化,意味着重音在活语言中来来去去。

在搜索之外,去除变音符号也有助于使 URL 更具可读性,至少在基于拉丁语的语言中是这样。看看维基百科上关于圣保罗市的文章 URL:

复制代码
https://en.wikipedia.org/wiki/S%C3%A3o_Paulo

%C3%A3 部分是单个字母 "ã"(带波浪号的 a)的 URL 转义、UTF-8 渲染。以下 URL 即使不是正确的拼写,也更容易识别:

复制代码
https://en.wikipedia.org/wiki/Sao_Paulo

要从一个 str 中移除所有变音符号,你可以使用像示例 4-14 这样的函数。

示例 4-14. simplify.py:移除所有组合标记的函数

python 复制代码
import unicodedata
import string

def shave_marks(txt):
    """移除所有变音符号"""
    norm_txt = unicodedata.normalize('NFD', txt)   # 将所有字符分解为基本字符和组合标记
    shaved = ''.join(c for c in norm_txt
                     if not unicodedata.combining(c))  # 过滤掉所有组合标记
    return unicodedata.normalize('NFC', shaved)    # 重新组合所有字符

示例 4-15 展示了 shave_marks 的几次使用。

示例 4-15. 使用示例 4-14 中 shave_marks 的两个例子

python 复制代码
>>> order = '"Herr Voß: • ½ cup of Œtker™ caffè latte • bowl of açaí."'
>>> shave_marks(order)
'"Herr Voß: • ½ cup of Œtker™ caffe latte • bowl of acai."'
>>> Greek = 'Ζέφυρος, Zéfiro'
>>> shave_marks(Greek)
'Ζεφυρος, Zefiro'
  • 只有字母 "è"、"ç" 和 "í" 被替换了。
  • "έ" 和 "é" 都被替换了。

示例 4-14 中的函数 shave_marks 工作正常,但可能走得太远了。通常移除变音符号的原因是将拉丁文本更改为纯 ASCII,但 shave_marks 也更改了非拉丁字符------如希腊字母------这些字符仅仅通过失去重音永远不会变成 ASCII。因此,分析每个基本字符,并且仅在基本字符是拉丁字母时才移除附加的标记是有意义的。示例 4-16 正是这样做的。

示例 4-16. 从拉丁字符中移除组合标记的函数(导入语句省略,因为这是示例 4-14 中 simplify.py 模块的一部分)

python 复制代码
def shave_marks_latin(txt):
    """从拉丁基本字符中移除所有变音符号"""
    norm_txt = unicodedata.normalize('NFD', txt)   # 分解
    latin_base = False
    preserve = []
    for c in norm_txt:
        if unicodedata.combining(c) and latin_base:
            continue    # 忽略拉丁基本字符上的组合标记
        preserve.append(c)
        # 如果不是组合字符,那就是新的基本字符
        if not unicodedata.combining(c):
            latin_base = c in string.ascii_letters
    shaved = ''.join(preserve)
    return unicodedata.normalize('NFC', shaved)    # 重新组合
  • 当基本字符是拉丁文时,跳过组合标记。
  • 否则,保留当前字符。
  • 检测新的基本字符,并判断它是否为拉丁文。

一个更激进的步骤是将西方文本中的常见符号(如弯引号、长破折号、项目符号等)替换为 ASCII 等价物。这就是示例 4-17 中 asciize 函数所做的。

示例 4-17. 将一些西方排版符号转换为 ASCII(此片段也是示例 4-14 中 simplify.py 的一部分)

python 复制代码
single_map = str.maketrans("""‚ƒ„ˆ‹''""•-----˜›""",
                           """'f"^<''""---~>""")

multi_map = str.maketrans({
    '€': 'EUR',
    '...': '...',
    'Æ': 'AE',
    'æ': 'ae',
    'Œ': 'OE',
    'œ': 'oe',
    '™': '(TM)',
    '‰': '<per mille>',
    '†': '**',
    '‡': '***',
})

multi_map.update(single_map)

def dewinize(txt):
    """将 Win1252 符号替换为 ASCII 字符或序列"""
    return txt.translate(multi_map)

def asciize(txt):
    no_marks = shave_marks_latin(dewinize(txt))
    no_marks = no_marks.replace('ß', 'ss')
    return unicodedata.normalize('NFKC', no_marks)
  • 构建字符到字符的映射表。
  • 构建字符到字符串的映射表。
  • 合并映射表。
  • dewinize 不影响 ASCII 或 latin1 文本,只影响 cp1252 中微软对 latin1 的添加部分。
  • 应用 dewinize 并移除变音符号。
  • 将 Eszett 替换为 "ss"(我们这里不使用大小写折叠,因为我们希望保留大小写)。
  • 应用 NFKC 规范化,将字符与其兼容性码位组合。

示例 4-18 展示了 asciize 的使用。

示例 4-18. 使用示例 4-17 中 asciize 的两个例子

python 复制代码
>>> order = '"Herr Voß: • ½ cup of Œtker™ caffè latte • bowl of açaí."'
>>> dewinize(order)
'"Herr Voß: - ½ cup of OEtker(TM) caffè latte - bowl of açaí."'
>>> asciize(order)
'"Herr Voss: - 1⁄2 cup of OEtker(TM) caffe latte - bowl of acai."'
  • dewinize 替换了弯引号、项目符号和 ™(商标符号)。
  • asciize 应用 dewinize,去除变音符号,并替换 'ß'

不同的语言有自己的移除变音符号的规则。例如,德国人将 'ü' 改为 'ue'。我们的 asciize 函数没有那么精细,因此它可能适合也可能不适合你的语言。不过,对于葡萄牙语,它工作得还算可以。

总结一下,simplify.py 中的函数远远超出了标准规范化,对文本进行了深度外科手术,极有可能改变其含义。只有你才能决定是否要走这么远,这取决于目标语言、你的用户以及转换后的文本将如何使用。

至此,我们结束了对规范化 Unicode 文本的讨论。现在让我们来解决 Unicode 排序问题。

排序 Unicode 文本

Python 通过对序列中的项逐个进行比较来对任何类型的序列进行排序。对于字符串,这意味着比较码位。不幸的是,对于任何使用非 ASCII 字符的人来说,这会产生不可接受的结果。

考虑对巴西种植的水果列表进行排序:

python 复制代码
>>> fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
>>> sorted(fruits)
['acerola', 'atemoia', 'açaí', 'caju', 'cajá']

不同语言环境的排序规则不同,但在葡萄牙语和许多使用拉丁字母的语言中,重音和软音符在排序时很少产生影响。因此,"cajá" 应按 "caja" 排序,并位于 "caju" 之前。

排序后的水果列表应为:

python 复制代码
['açaí', 'acerola', 'atemoia', 'cajá', 'caju']

在 Python 中对非 ASCII 文本进行排序的标准方法是使用 locale.strxfrm 函数,根据 locale 模块文档,它"将字符串转换为可用于语言环境感知比较的形式"。

要启用 locale.strxfrm,你必须首先为你的应用程序设置一个合适的语言环境,并祈祷操作系统支持它。示例 4-19 中的命令序列可能对你有效。

示例 4-19. locale_sort.py:使用 locale.strxfrm 函数作为排序键

python 复制代码
import locale

my_locale = locale.setlocale(locale.LC_COLLATE, 'pt_BR.UTF-8')
print(my_locale)

fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
sorted_fruits = sorted(fruits, key=locale.strxfrm)
print(sorted_fruits)

在安装了 pt_BR.UTF-8 语言环境的 GNU/Linux(Ubuntu 19.10)上运行示例 4-19,我得到了正确的结果:

复制代码
'pt_BR.UTF-8'
['açaí', 'acerola', 'atemoia', 'cajá', 'caju']

因此,你需要在排序时使用 locale.strxfrm 作为 key 之前调用 setlocale(LC_COLLATE, <<your_locale>>)

不过,有一些注意事项:

  • 因为语言环境设置是全局的,在库中调用 setlocale 是不推荐的。你的应用程序或框架应该在进程启动时设置语言环境,并且之后不应更改。
  • 语言环境必须安装在操作系统上,否则 setlocale 会引发 locale.Error: unsupported locale setting 异常。
  • 你必须知道如何拼写语言环境名称。
  • 语言环境必须由操作系统的制作者正确实现。我在 Ubuntu 19.10 上成功了,但在 macOS 10.14 上没有成功。在 macOS 上,调用 setlocale(LC_COLLATE, 'pt_BR.UTF-8') 返回字符串 'pt_BR.UTF-8' 而没有抱怨。但是 sorted(fruits, key=locale.strxfrm) 产生了与 sorted(fruits) 相同的错误结果。我也在 macOS 上尝试了 fr_FRes_ESde_DE 语言环境,但 locale.strxfrm 从未发挥作用。

因此,标准库中对国际化排序的支持是有效的,但似乎只在 GNU/Linux 上得到良好支持(也许在 Windows 上也支持,如果你是个专家)。即便如此,它也依赖于语言环境设置,造成了部署上的麻烦。

幸运的是,有一个更简单的解决方案:pyuca 库,可在 PyPI 上获得。

使用 Unicode 排序算法排序

多产的 Django 贡献者 James Tauber 一定感受到了这种痛苦,并创建了 pyuca,一个纯 Python 实现的 Unicode 排序算法。示例 4-20 展示了使用它有多么简单。

示例 4-20. 使用 pyuca.Collator.sort_key 方法

python 复制代码
>>> import pyuca
>>> coll = pyuca.Collator()
>>> fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
>>> sorted_fruits = sorted(fruits, key=coll.sort_key)
>>> sorted_fruits
['açaí', 'acerola', 'atemoia', 'cajá', 'caju']

这很简单,并且在 GNU/Linux、macOS 和 Windows 上都能工作,至少在我的小样本上是这样。

pyuca 没有考虑语言环境。如果你需要自定义排序,你可以向 Collator() 构造函数提供自定义排序表的路径。开箱即用时,它使用捆绑在项目中的 allkeys.txt。那只是从 Unicode.org 复制的默认 Unicode 排序元素表。

PyICU:Miro 对 Unicode 排序的推荐

(技术审阅者 Miroslav Šedivý 精通多种语言,是 Unicode 专家。以下是他关于 pyuca 的评论。)

pyuca 有一种排序算法,不尊重各个语言中的排序顺序。例如,Ä 在德语中介于 A 和 B 之间,而在瑞典语中它排在 Z 之后。请看 PyICU,它的工作方式与 locale 相似,但不会改变进程的语言环境。如果你想要更改土耳其语中 iİ/ıI 的大小写,也需要它。PyICU 包含一个必须编译的扩展,因此在某些系统上可能比纯 Python 的 pyuca 更难安装。

顺便说一下,那个排序表是构成 Unicode 数据库的众多数据文件之一,这是我们接下来的主题。

Unicode 数据库

Unicode 标准提供了一个完整的数据库------以几个结构化文本文件的形式------不仅包括将码位映射到字符名称的表,还包括关于单个字符及其相互关系的元数据。例如,Unicode 数据库记录一个字符是否可打印、是否为字母、是否为十进制数字,或是否为某种其他数字符号。这就是 str 方法 isalphaisprintableisdecimalisnumeric 的工作方式。str.casefold 也使用来自 Unicode 表的信息。

unicodedata.category(char) 函数从 Unicode 数据库返回 char 的双字母类别。更高级别的 str 方法更容易使用。例如,如果 label 中的每个字符属于以下类别之一:LmLtLuLlLo,则 label.isalpha() 返回 True。要了解这些代码的含义,请参阅英文维基百科的"Unicode character property"文章中的"General Category"。

按名称查找字符

unicodedata 模块有检索字符元数据的函数,包括 unicodedata.name(),它返回字符在标准中的官方名称。图 4-5 演示了该函数。

你可以使用 name() 函数来构建允许用户按字符名称搜索字符的应用程序。图 4-6 演示了 cf.py 命令行脚本,它接受一个或多个单词作为参数,并列出其官方 Unicode 名称中包含这些单词的字符。cf.py 的完整源代码在示例 4-21 中。

表情符号支持在不同操作系统和应用程序之间差异很大。近年来,macOS 终端对表情符号提供了最好的支持,其次是现代 GNU/Linux 图形终端。Windows cmd.exe 和 PowerShell 现在支持 Unicode 输出,但在 2020 年 1 月我写本节时,它们仍然不显示表情符号------至少不是"开箱即用"。技术审阅者 Leonardo Rochael 告诉我微软有一个新的开源 Windows 终端,可能比旧的微软控制台有更好的 Unicode 支持。我没有时间尝试。

在示例 4-21 中,注意 find 函数中的 if 语句使用了 .issubset() 方法,快速测试查询集中的所有单词是否都出现在从字符名称构建的单词列表中。得益于 Python 丰富的集合 API,我们不需要嵌套的 for 循环和另一个 if 来实现这个检查。

示例 4-21. cf.py:字符查找实用程序

python 复制代码
#!/usr/bin/env python3
import sys
import unicodedata

START, END = ord(' '), sys.maxunicode + 1

def find(*query_words, start=START, end=END):
    query = {w.upper() for w in query_words}
    for code in range(start, end):
        char = chr(code)
        name = unicodedata.name(char, None)
        if name and query.issubset(name.split()):
            print(f'U+{code:04X}\t{char}\t{name}')

def main(words):
    if words:
        find(*words)
    else:
        print('Please provide words to find.')

if __name__ == '__main__':
    main(sys.argv[1:])
  • 设置要搜索的码位范围的默认值。
  • find 接受 query_words 和可选的关键字参数以限制搜索范围,便于测试。
  • query_words 转换为一组大写的字符串。
  • 获取 code 对应的 Unicode 字符。
  • 获取字符的名称,如果码位未分配则返回 None
  • 如果有名称,将其拆分为单词列表,然后检查查询集是否为该列表的子集。
  • 打印一行,包含 U+9999 格式的码位、字符及其名称。

unicodedata 模块还有其他有趣的函数。接下来,我们将看到一些与从具有数字意义的字符中获取信息相关的函数。

字符的数值含义

unicodedata 模块包含检查 Unicode 字符是否表示数字的函数,如果是,则返回其人类可读的数值------而不是其码位数字。示例 4-22 展示了 unicodedata.name()unicodedata.numeric() 的使用,以及 str.isdecimal().isnumeric() 方法。

示例 4-22. Unicode 数据库数字字符元数据演示(标注描述了输出中的每一列)

python 复制代码
import unicodedata
import re

re_digit = re.compile(r'\d')
sample = '1\xbc\xb2\u0969\u136b\u216b\u2466\u2480\u3285'

for char in sample:
    print(f'U+{ord(char):04x}',
          char.center(6),
          're_dig' if re_digit.match(char) else '-',
          'isdig' if char.isdigit() else '-',
          'isnum' if char.isnumeric() else '-',
          f'{unicodedata.numeric(char):5.2f}',
          unicodedata.name(char),
          sep='\t')
  • U+0000 格式的码位。
  • 字符居中于一个长度为 6 的字符串中。
  • 如果字符匹配正则表达式 r'\d',显示 re_dig
  • 如果 char.isdigit()True,显示 isdig
  • 如果 char.isnumeric()True,显示 isnum
  • 数值,格式化为宽度 5,小数点后 2 位。
  • Unicode 字符名称。

运行示例 4-22 会得到图 4-7,如果你的终端字体包含所有这些字形的话。

图 4-7 的第六列是对该字符调用 unicodedata.numeric(char) 的结果。它显示 Unicode 知道表示数字的符号的数值。因此,如果你想创建一个支持泰米尔数字或罗马数字的电子表格应用程序,尽管去尝试吧!

图 4-7 显示,正则表达式 r'\d' 匹配数字 "1" 和天城文数字 3,但不匹配被 isdigit 函数视为数字的其他字符。re 模块在 Unicode 方面不够敏锐。PyPI 上新的 regex 模块旨在最终取代 re,并提供更好的 Unicode 支持。我们将在下一节回到 re 模块。

在本章中,我们使用了几个 unicodedata 函数,但还有很多我们没有涵盖。请参阅标准库文档中的 unicodedata 模块。

接下来,我们将快速了解一下双模式 API,这些 API 提供的函数接受 strbytes 参数,并根据类型进行特殊处理。

双模式 strbytes API

Python 的标准库中有一些函数接受 strbytes 参数,并依据类型表现出不同的行为。一些例子可以在 reos 模块中找到。

正则表达式中的 strbytes

如果你用 bytes 构建正则表达式,像 \d\w 这样的模式只匹配 ASCII 字符;相反,如果这些模式以 str 形式给出,它们匹配 ASCII 之外的 Unicode 数字或字母。示例 4-23 和图 4-8 比较了 strbytes 模式如何匹配字母、ASCII 数字、上标和泰米尔数字。

示例 4-23. ramanujan.py:比较简单的 strbytes 正则表达式的行为

python 复制代码
import re

re_numbers_str = re.compile(r'\d+')
re_words_str = re.compile(r'\w+')
re_numbers_bytes = re.compile(rb'\d+')
re_words_bytes = re.compile(rb'\w+')

text_str = ("Ramanujan saw \u0be7\u0bed\u0be8\u0bef"
            " as 1729 = 1³ + 12³ = 9³ + 10³.")
text_bytes = text_str.encode('utf_8')

print(f'Text\n  {text_str!r}')
print('Numbers')
print('  str :', re_numbers_str.findall(text_str))
print('  bytes:', re_numbers_bytes.findall(text_bytes))
print('Words')
print('  str :', re_words_str.findall(text_str))
print('  bytes:', re_words_bytes.findall(text_bytes))
  • 前两个正则表达式是 str 类型。
  • 后两个是 bytes 类型。
  • 要搜索的 Unicode 文本,包含泰米尔数字 1729。
  • 需要一个 bytes 字符串来使用 bytes 正则表达式进行搜索。
  • str 模式 r'\d+' 匹配泰米尔数字和 ASCII 数字。
  • bytes 模式 rb'\d+' 只匹配 ASCII 字节的数字。
  • str 模式 r'\w+' 匹配字母、上标、泰米尔数字和 ASCII 数字。
  • bytes 模式 rb'\w+' 只匹配 ASCII 字节的字母和数字。

示例 4-23 是一个简单的例子,只是为了说明一点:你可以在 strbytes 上使用正则表达式,但在后一种情况下,ASCII 范围之外的字节被视为非数字和非单词字符。

对于 str 正则表达式,有一个 re.ASCII 标志,使 \w\W\b\B\d\D\s\S 仅执行 ASCII 匹配。有关详细信息,请参阅 re 模块的文档。

另一个重要的双模式模块是 os

os 函数中的 strbytes

GNU/Linux 内核不具备 Unicode 感知能力,因此在现实世界中,你可能会遇到由字节序列组成的文件名,这些字节序列在任何合理的编码方案中都无效,并且无法解码为 str。使用各种操作系统的客户端的文件服务器特别容易出现这个问题。

为了解决这个问题,所有接受文件名或路径名的 os 模块函数都接受 strbytes 参数。如果使用 str 参数调用这样的函数,该参数将使用 sys.getfilesystemencoding() 命名的编解码器自动转换,并且操作系统的响应将使用相同的编解码器解码。这几乎总是你想要的,符合 Unicode 三明治的最佳实践。

但是,如果你必须处理(也许还要修复)无法以这种方式处理的文件名,你可以向 os 函数传递 bytes 参数,以获得 bytes 返回值。这个特性允许你处理任何文件或路径名,无论你找到多少乱码。见示例 4-24。

示例 4-24. 使用 strbytes 参数及结果的 listdir

python 复制代码
>>> os.listdir('.')
['abc.txt', 'digits-of-π.txt']
>>> os.listdir(b'.')
[b'abc.txt', b'digits-of-\xcf\x80.txt']
  • 第二个文件名是 "digits-of-π.txt"(带有希腊字母 pi)。
  • 给定一个 bytes 参数,listdir 返回作为 bytes 的文件名:b'\xcf\x80' 是希腊字母 pi 的 UTF-8 编码。

为了帮助手动处理作为文件名或路径名的 strbytes 序列,os 模块提供了特殊的编码和解码函数 os.fsencode(name_or_path)os.fsdecode(name_or_path)。从 Python 3.6 开始,这两个函数都接受 strbytes 或实现 os.PathLike 接口的对象作为参数。

Unicode 是一个深不见底的兔子洞。是时候结束我们对 strbytes 的探索了。

本章小结

我们从驳斥 1 个字符 == 1 个字节的观念开始本章。随着世界采用 Unicode,我们需要将文本字符串的概念与在文件中表示它们的二进制序列分开,而 Python 3 强制执行了这种分离。

在简要概述了二进制序列数据类型------bytesbytearraymemoryview------之后,我们深入探讨了编码和解码,采样了重要的编解码器,接着介绍了预防或处理臭名昭著的 UnicodeEncodeErrorUnicodeDecodeError 以及由 Python 源文件中错误编码引起的 SyntaxError 的方法。

然后,我们考虑了在缺少元数据的情况下进行编码检测的理论与实践:理论上,这是不可能的,但在实践中,Chardet 包对于许多流行编码都相当成功地做到了。随后介绍了字节顺序标记,这是 UTF-16 和 UTF-32 文件中常见的唯一编码提示------有时也在 UTF-8 文件中出现。

在下一节中,我们演示了打开文本文件,这本来是一项简单的任务,除了一个陷阱:当你打开文本文件时,encoding= 关键字参数不是必须的,但它应该是必须的。如果你未能指定编码,你最终得到的程序所生成的"纯文本"会因默认编码冲突而在不同平台间不兼容。然后,我们揭示了 Python 用作默认值的不同编码设置以及如何检测它们。对于 Windows 用户来说,一个令人沮丧的现实是,这些设置在同一台机器上通常有不同的值,并且这些值互不兼容;相比之下,GNU/Linux 和 macOS 用户生活在一个更幸福的地方,UTF-8 几乎随处可见。

Unicode 提供了多种表示某些字符的方式,因此规范化是文本匹配的先决条件。除了解释规范化和大小写折叠外,我们还提供了一些你可能根据自己需要改编的实用函数,包括去除所有重音等激烈转换。然后,我们看到了如何通过使用标准 locale 模块------有一些注意事项------以及不依赖棘手语言环境配置的替代方案:外部 pyuca 包,来正确排序 Unicode 文本。

我们利用 Unicode 数据库编写了一个命令行实用程序,按名称搜索字符------仅仅用了 28 行代码,这得益于 Python 的强大功能。我们简要了解了其他 Unicode 元数据,并概述了双模式 API,其中一些函数可以用 strbytes 参数调用,产生不同的结果。

进一步阅读

Ned Batchelder 在 2012 年 PyCon US 上的演讲"Pragmatic Unicode, or, How Do I Stop the Pain?"非常出色。Ned 非常专业,他提供了演讲的完整文字记录以及幻灯片和视频。

Esther Nam 和 Travis Fischer 在 2014 年 PyCon 上的精彩演讲"Character encoding and Unicode in Python: How to (╯°□°)╯︵ ┻━┻ with dignity"(幻灯片、视频)中,我找到了本章精辟的题记:"人类使用文本。计算机使用字节。"

Lennart Regebro------本书第一版的技术审阅者之一------在短文"Unconfusing Unicode: What Is Unicode?"中分享了他的"Unicode 有用心智模型"。Unicode 是一个复杂的标准,因此 Lennart 的 UMMU 是一个真正有用的起点。

Python 文档中的官方"Unicode HOWTO"从几个不同的角度探讨了这个主题,从良好的历史介绍到语法细节、编解码器、正则表达式、文件名以及 Unicode 感知 I/O 的最佳实践(即 Unicode 三明治),每个部分都有大量额外的参考链接。Mark Pilgrim 的精彩著作《Dive into Python 3》(Apress)的第 4 章"字符串"也很好地介绍了 Python 3 中的 Unicode 支持。在同一本书中,第 15 章描述了 Chardet 库如何从 Python 2 移植到 Python 3,这是一个有价值的案例研究,因为从旧的 str 切换到新的 bytes 是大多数迁移痛苦的原因,而这是一个以检测编码为核心的库的中心问题。

如果你了解 Python 2 但对 Python 3 不熟悉,Guido van Rossum 的"What's New in Python 3.0"有 15 个要点总结了变化,并附有许多链接。Guido 以直率的声明开头:"你以为你知道的关于二进制数据和 Unicode 的一切都已经改变了。"Armin Ronacher 的博客文章"The Updated Guide to Unicode on Python"很深入,并强调了 Python 3 中 Unicode 的一些陷阱(Armin 不是 Python 3 的狂热粉丝)。

David Beazley 和 Brian K. Jones 合著的《Python Cookbook》第 3 版(O'Reilly)的第 2 章"字符串和文本"有几个处理 Unicode 规范化、清理文本以及对字节序列执行面向文本的操作的食谱。第 5 章涵盖了文件和 I/O,其中包括"Recipe 5.17. Writing Bytes to a Text File",展示了任何文本文件之下总是有一个二进制流,必要时可以直接访问。在该食谱的后面,struct 模块在"Recipe 6.11. Reading and Writing Binary Arrays of Structures"中得到应用。

Nick Coghlan 的"Python Notes"博客有两篇与本章非常相关的文章:"Python 3 and ASCII Compatible Binary Protocols"和"Processing Text Files in Python 3"。强烈推荐。

Python 支持的编码列表可在 codecs 模块文档的"Standard Encodings"中找到。如果你需要以编程方式获取该列表,请参阅 CPython 源代码附带的 /Tools/unicode/listcodecs.py 脚本中的做法。

Jukka K. Korpela 的《Unicode Explained》(O'Reilly)和 Richard Gillam 的《Unicode Demystified》(Addison-Wesley)不是专门针对 Python 的,但在我学习 Unicode 概念时非常有帮助。Victor Stinner 的《Programming with Unicode》是一本免费的、自行出版的书籍(Creative Commons BY-SA 许可),涵盖了通用 Unicode,以及主要操作系统和几种编程语言(包括 Python)中的工具和 API。

W3C 的页面"Case Folding: An Introduction"和"Character Model for the World Wide Web: String Matching"涵盖了规范化概念,前者是温和的介绍,后者是以枯燥的标准语言编写的工作组笔记------语气与"Unicode Standard Annex #15---Unicode Normalization Forms"相同。Unicode.org 的"Frequently Asked Questions, Normalization"部分更具可读性,Mark Davis 的"NFC FAQ"也是如此------Davis 是几个 Unicode 算法的作者,在撰写本文时是 Unicode 联盟的主席。

2016 年,纽约现代艺术博物馆将原始表情符号------Shigetaka Kurita 于 1999 年为日本移动运营商 NTT DOCOMO 设计的 176 个表情符号------收藏入馆。回溯历史,Emojipedia 发表了"Correcting the Record on the First Emoji Set",认为日本 SoftBank 公司于 1997 年在手机上部署了已知最早的表情符号集。SoftBank 的这套表情符号是现在 Unicode 中 90 个表情符号的来源,包括 U+1F4A9(PILE OF POO)。Matthew Rothenberg 的 emojitracker.com 是一个实时仪表板,显示 Twitter 上表情符号的使用计数,实时更新。在我写这篇文章时,带有喜悦泪水的脸(U+1F602)是 Twitter 上最受欢迎的表情符号,记录出现次数超过 3,313,667,315 次。

讨论区

源代码中的非 ASCII 名称:你应该使用它们吗?

Python 3 允许在源代码中使用非 ASCII 标识符:

python 复制代码
>>> ação = 'PBR'   # ação = 股票
>>> ε = 10**-6     # ε = 艾普西隆

有些人反对这个想法。坚持使用 ASCII 标识符的最常见论据是让每个人都能轻松阅读和编辑代码。这个论点没有抓住要点:你希望你的源代码能被其目标受众阅读和编辑,而这个受众可能不是"每个人"。如果代码属于跨国公司,或者是开源的,并且你希望来自世界各地的贡献者,那么标识符应该是英文的,那么你只需要 ASCII。

但是,如果你是巴西的一名教师,你的学生会发现使用正确拼写的葡萄牙语变量和函数名的代码更容易阅读。而且他们在本地化键盘上输入软音符和带重音的元音没有任何困难。

现在 Python 可以解析 Unicode 名称,并且 UTF-8 是默认的源编码,我认为用没有重音的葡萄牙语编写标识符没有意义,就像我们在 Python 2 中出于必要所做的那样------除非你的代码也需要在 Python 2 上运行。如果名称是葡萄牙语的,省略重音不会让任何人更容易阅读代码。

这是我作为一个说葡萄牙语的巴西人的观点,但我相信这适用于国界和文化之外:选择使代码更容易被团队阅读的人类语言,然后使用正确拼写所需的字符。

什么是"纯文本"?

对于每天处理非英语文本的人来说,"纯文本"并不意味"ASCII"。Unicode 词汇表这样定义纯文本:

计算机编码的文本,仅包含来自给定标准的码位序列,没有其他格式或结构信息。

这个定义开头很好,但我不同意逗号后面的部分。HTML 是一个很好的纯文本格式示例,它携带格式和结构信息。但它仍然是纯文本,因为这种文件中的每个字节都是为了表示一个文本字符,通常使用 UTF-8。没有像在 .png.xls 文档中那样的具有非文本含义的字节,其中大多数字节表示打包的二进制值,如 RGB 值和浮点数。在纯文本中,数字表示为数字字符的序列。

我正在用一种名为------讽刺的是------AsciiDoc 的纯文本格式写这本书,这是 O'Reilly 优秀的 Atlas 图书出版平台工具链的一部分。AsciiDoc 源文件是纯文本,但它们是 UTF-8,而不是 ASCII。否则,写这一章将非常痛苦。尽管名称如此,AsciiDoc 还是很棒。

Unicode 的世界在不断扩大,在边缘,工具支持并不总是存在。并非我想展示的所有字符都在用于渲染本书的字体中可用。这就是为什么我不得不在本章的几个示例中使用图像而不是列表。另一方面,Ubuntu 和 macOS 终端能很好地显示大多数 Unicode 文本------包括"mojibake"的日语字符:文字化け。

str 的码位在 RAM 中是如何表示的?

官方 Python 文档回避了 str 的码位如何在内存中存储的问题。这实际上是一个实现细节。理论上,这并不重要:无论内部表示如何,每个 str 在输出时都必须编码为 bytes

在内存中,Python 3 将每个 str 存储为码位序列,每个码位使用固定数量的字节,以允许高效地直接访问任何字符或切片。

自 Python 3.3 起,在创建新的 str 对象时,解释器会检查其中的字符,并为该特定的 str 选择最经济的内存布局:如果只有 latin1 范围内的字符,该 str 将每个码位只使用一个字节。否则,根据 str 的不同,可能使用每码位两个或四个字节。这是一个简化;有关完整细节,请参阅 PEP 393------灵活字符串表示。

灵活的字符串表示类似于 Python 3 中 int 类型的工作方式:如果整数适合机器字,则存储在一个机器字中。否则,解释器切换到类似于 Python 2 long 类型的变长表示。看到好思想的传播是件好事。

然而,我们总是可以指望 Armin Ronacher 在 Python 3 中发现问题。他向我解释了为什么这在实践中不是一个好主意:只需要一个 RAT(U+1F400)就能将原本全是 ASCII 的文本膨胀成一个每个字符使用四个字节的内存消耗大户,而除了 RAT 之外的每个字符本应使用一个字节就够了。此外,由于 Unicode 字符组合的各种方式,按位置快速检索任意字符的能力被高估了------并且从 Unicode 文本中提取任意切片充其量是天真的,而且常常是错误的,会产生乱码。随着表情符号越来越流行,这些问题只会变得更糟。


代码示例

以下是根据第四章《Unicode 文本与字节》知识点整理的所有代码示例,按书中出现的顺序组织,并配有对应的标题。每个示例均可独立运行。


示例 4-1:编码与解码基础

python 复制代码
# 编码:str -> bytes
s = 'café'
print(len(s))                     # 4
b = s.encode('utf8')
print(b)                          # b'caf\xc3\xa9'
print(len(b))                     # 5

# 解码:bytes -> str
print(b.decode('utf8'))           # café

示例 4-2:bytesbytearray 的基本操作

python 复制代码
# 从字符串构建 bytes
cafe = bytes('café', encoding='utf_8')
print(cafe)                       # b'caf\xc3\xa9'
print(cafe[0])                    # 99(整数)
print(cafe[:1])                   # b'c'

# bytearray 转换
cafe_arr = bytearray(cafe)
print(cafe_arr)                   # bytearray(b'caf\xc3\xa9')
print(cafe_arr[-1:])              # bytearray(b'\xa9')

示例 4-3:从数组原始数据初始化 bytes

python 复制代码
import array
numbers = array.array('h', [-2, -1, 0, 1, 2])   # 有符号短整数
octets = bytes(numbers)
print(octets)                      # b'\xfe\xff\xff\xff\x00\x00\x01\x00\x02\x00'

示例 4-4:不同编码产生不同的字节序列

python 复制代码
for codec in ['latin_1', 'utf_8', 'utf_16']:
    print(codec, 'El Niño'.encode(codec), sep='\t')
# 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'

示例 4-5:编码错误处理(UnicodeEncodeError

python 复制代码
city = 'São Paulo'

# 成功编码
print(city.encode('utf_8'))        # b'S\xc3\xa3o Paulo'
print(city.encode('iso8859_1'))    # b'S\xe3o Paulo'

# cp437 无法编码 'ã'
try:
    city.encode('cp437')
except UnicodeEncodeError as e:
    print('ERROR:', e)

# 不同错误处理方式
print(city.encode('cp437', errors='ignore'))        # b'So Paulo'
print(city.encode('cp437', errors='replace'))       # b'S?o Paulo'
print(city.encode('cp437', errors='xmlcharrefreplace'))  # b'S&#227;o Paulo'

示例 4-6:解码错误处理(UnicodeDecodeError

python 复制代码
octets = b'Montr\xe9al'

# 不同解码结果
print(octets.decode('cp1252'))     # Montréal
print(octets.decode('iso8859_7'))  # Montrιal
print(octets.decode('koi8_r'))     # MontrИal

# UTF-8 解码错误
try:
    octets.decode('utf_8')
except UnicodeDecodeError as e:
    print('ERROR:', e)

print(octets.decode('utf_8', errors='replace'))  # Montr�al

示例 4-7:源文件编码声明(ola.py

python 复制代码
# 文件 ola.py 内容
# coding: cp1252
print('Olá, Mundo!')

示例 4-8 与 4-9:文本文件读写与默认编码陷阱

python 复制代码
# 写入时指定编码,读取时未指定导致乱码
open('cafe.txt', 'w', encoding='utf_8').write('café')
print(open('cafe.txt').read())          # 可能显示 café(取决于平台)

# 正确做法:显式指定相同编码
print(open('cafe.txt', encoding='utf_8').read())  # café

# 以二进制模式读取
print(open('cafe.txt', 'rb').read())     # b'caf\xc3\xa9'

示例 4-10:查看系统编码默认值

python 复制代码
import locale
import sys

print('locale.getpreferredencoding():', locale.getpreferredencoding())
print('sys.stdout.encoding:', sys.stdout.encoding)
print('sys.stdin.encoding:', sys.stdin.encoding)
print('sys.stderr.encoding:', sys.stderr.encoding)
print('sys.getdefaultencoding():', sys.getdefaultencoding())
print('sys.getfilesystemencoding():', sys.getfilesystemencoding())

示例 4-11:Windows 控制台编码示例(需在 Windows 上运行)

bash 复制代码
chcp
python default_encodings.py

示例 4-12:标准输出重定向与编码错误

python 复制代码
# stdout_check.py
import sys
from unicodedata import name

print(sys.version)
print('sys.stdout.isatty():', sys.stdout.isatty())
print('sys.stdout.encoding:', sys.stdout.encoding)
for char in ['...', '∞', '㊷']:
    print(f'Trying to output {name(char)}:')
    print(char)

示例 4-13:规范化比较函数(normeq.py

python 复制代码
from unicodedata import normalize

def nfc_equal(str1, str2):
    return normalize('NFC', str1) == normalize('NFC', str2)

def fold_equal(str1, str2):
    return (normalize('NFC', str1).casefold() ==
            normalize('NFC', str2).casefold())

# 测试
s1 = 'café'
s2 = 'cafe\u0301'
print(s1 == s2)                     # False
print(nfc_equal(s1, s2))            # True

s3 = 'Straße'
s4 = 'strasse'
print(fold_equal(s3, s4))           # True

示例 4-14:移除变音符号(shave_marks

python 复制代码
import unicodedata

def shave_marks(txt):
    """移除所有组合标记(变音符号)"""
    norm_txt = unicodedata.normalize('NFD', txt)
    shaved = ''.join(c for c in norm_txt if not unicodedata.combining(c))
    return unicodedata.normalize('NFC', shaved)

order = '"Herr Voß: • ½ cup of Œtker™ caffè latte • bowl of açaí."'
print(shave_marks(order))   # "Herr Voß: • ½ cup of Œtker™ caffe latte • bowl of acai."

示例 4-16:仅对拉丁字符移除变音符号

python 复制代码
import string
import unicodedata

def shave_marks_latin(txt):
    norm_txt = unicodedata.normalize('NFD', txt)
    latin_base = False
    preserve = []
    for c in norm_txt:
        if unicodedata.combining(c) and latin_base:
            continue
        preserve.append(c)
        if not unicodedata.combining(c):
            latin_base = c in string.ascii_letters
    return unicodedata.normalize('NFC', ''.join(preserve))

print(shave_marks_latin('Zéfiro, Ζέφυρος'))  # Zefiro, Ζέφυρος(希腊字母保持原样)

示例 4-17:转换为 ASCII 的激进函数(asciize

python 复制代码
# 部分映射表(完整版见书中)
single_map = str.maketrans("""‚ƒ„ˆ‹''""•-----˜›""", """'f"^<''""---~>""")
multi_map = str.maketrans({'€': 'EUR', '...': '...', '™': '(TM)'})
multi_map.update(single_map)

def dewinize(txt):
    return txt.translate(multi_map)

def asciize(txt):
    no_marks = shave_marks_latin(dewinize(txt))
    no_marks = no_marks.replace('ß', 'ss')
    return unicodedata.normalize('NFKC', no_marks)

order = '"Herr Voß: • ½ cup of Œtker™ caffè latte"'
print(asciize(order))
# "Herr Voss: - 1⁄2 cup of OEtker(TM) caffe latte"

示例 4-19:使用 locale.strxfrm 排序

python 复制代码
import locale
locale.setlocale(locale.LC_COLLATE, 'pt_BR.UTF-8')
fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
sorted_fruits = sorted(fruits, key=locale.strxfrm)
print(sorted_fruits)   # ['açaí', 'acerola', 'atemoia', 'cajá', 'caju']

示例 4-20:使用 pyuca 进行 Unicode 排序

python 复制代码
# 需要先安装:pip install pyuca
import pyuca
coll = pyuca.Collator()
fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
print(sorted(fruits, key=coll.sort_key))

示例 4-21:字符查找工具 cf.py

python 复制代码
#!/usr/bin/env python3
import sys
import unicodedata

START, END = ord(' '), sys.maxunicode + 1

def find(*query_words, start=START, end=END):
    query = {w.upper() for w in query_words}
    for code in range(start, end):
        char = chr(code)
        name = unicodedata.name(char, None)
        if name and query.issubset(name.split()):
            print(f'U+{code:04X}\t{char}\t{name}')

if __name__ == '__main__':
    if len(sys.argv) > 1:
        find(*sys.argv[1:])
    else:
        print('Please provide words to find.')

命令行示例:

bash 复制代码
$ python cf.py smile cat
U+1F600    😀    GRINNING FACE
U+1F601    😁    BEAMING FACE WITH SMILING EYES
...

示例 4-22:检查字符的数字属性

python 复制代码
import unicodedata
import re

re_digit = re.compile(r'\d')
sample = '1\xbc\xb2\u0969\u136b\u216b\u2466\u2480\u3285'

for char in sample:
    print(f'U+{ord(char):04x}', char.center(6),
          're_dig' if re_digit.match(char) else '-',
          'isdig' if char.isdigit() else '-',
          'isnum' if char.isnumeric() else '-',
          f'{unicodedata.numeric(char):5.2f}',
          unicodedata.name(char), sep='\t')

示例 4-23:正则表达式中 strbytes 的差异

python 复制代码
import re

re_numbers_str = re.compile(r'\d+')
re_words_str = re.compile(r'\w+')
re_numbers_bytes = re.compile(rb'\d+')
re_words_bytes = re.compile(rb'\w+')

text_str = "Ramanujan saw \u0be7\u0bed\u0be8\u0bef as 1729 = 1³ + 12³"
text_bytes = text_str.encode('utf_8')

print('Numbers (str):', re_numbers_str.findall(text_str))
print('Numbers (bytes):', re_numbers_bytes.findall(text_bytes))
print('Words (str):', re_words_str.findall(text_str))
print('Words (bytes):', re_words_bytes.findall(text_bytes))

示例 4-24:os.listdirstrbytes 模式

python 复制代码
import os
print(os.listdir('.'))          # 返回字符串列表
print(os.listdir(b'.'))         # 返回字节串列表(处理无效编码文件名)

如果需要某个示例的完整代码文件,或者希望将所有示例放入一个可运行的脚本中,请告诉我。

相关推荐
测试员周周1 小时前
【AI测试系统】第1篇:LangGraph 实战:用 State Graph 搭建 AI测试流水线(4 步编排 + RAG 增强 + 完整代码)
linux·windows·python·功能测试·microsoft·单元测试·多轮对话
AI人工智能+电脑小能手1 小时前
【大白话说Java面试题】【Java基础篇】第20题:HashMap在计算index的时候,为什么要对数组长度做减1操作
java·开发语言·数据结构·后端·面试·哈希算法·hash-index
凯瑟琳.奥古斯特1 小时前
Bootstrap快速上手指南
开发语言·前端·css·bootstrap·html
噜噜噜阿鲁~1 小时前
python学习笔记 | 8.2、函数式编程-返回函数
笔记·python·学习
我就是妖怪2 小时前
Kimi K2.6 智能效果实测与能力全景展示
开发语言
中二痞2 小时前
下载Python 版本,环境变量变更以及PyCharm更换python版本
开发语言·python·pycharm
故事和你912 小时前
洛谷-算法2-3-分治与倍增5
开发语言·数据结构·c++·算法·动态规划·图论
SilentSamsara2 小时前
标准库精讲:collections/itertools/functools/pathlib 实战
开发语言·vscode·python·青少年编程·pycharm
小郑加油2 小时前
python学习Day8-9天:函数(def)的基础运用
python·学习