文本和字节序列
大纲
- 本章将讨论下述话题:
- 字符、码位和字节表述
- bytes、bytearray 和 memoryview 等二进制序列的独特特性
- 全部 Unicode 和陈旧字符集的编解码器
- 避免和处理编码错误
- 处理文本文件的最佳实践
- 默认编码的陷阱和标准 I/O 的问题
- 规范化 Unicode 文本,进行安全的比较
- 规范化、大小写折叠和暴力移除音调符号的实用函数
- 使用 locale 模块和 PyUCA 库正确地排序 Unicode 文本
- Unicode 数据库中的字符元数据
- 能处理字符串和字节序列的双模式 API
1. 字符问题
- “字符串”是个相当简单的概念:一个字符串是一个字符序列。问题出在“字符”的定义上。
- “字符”的最佳定义是 Unicode 字符。因此,从 Python 3 的str 对象中获取的元素是 Unicode 字符,这相当于从 Python 2 的unicode 对象中获取的元素,而不是从 Python 2 的 str 对象中获取的原始字节序列。Unicode 标准把字符的标识和具体的字节表述进行了如下的明确区分:
- 字符的标识,即
码位
,是 0~1 114 111 的数字(十进制),在Unicode 标准中以 4~6 个十六进制数字表示,而且加前缀“U+”。例如,字母 A 的码位是 U+0041,欧元符号的码位是 U+20AC,高音谱号的码位是 U+1D11E。 - 字符的具体表述取决于所用的编码。编码是在
码位
和字节序列
之间转换时使用的算法。在 UTF-8 编码中,A(U+0041)的码位编码成单个字节 \x41,而在 UTF-16LE 编码中编码成两个字节\x41\x00。
- 字符的标识,即
- 把码位转换成字节序列的过程是编码;把字节序列转换成码位的过程是解码。示例如下:
'''
示例 1 编码和解码
'''
if __name__ == '__main__':
s = "cafe"
b = s.encode(encoding="utf-8") # 使用 UTF-8 把 str 对象编码成 bytes 对象
print(b) # b'cafe'
s2 = b.decode(encoding="utf-8") # 使用 UTF-8 把 bytes 对象解码成 str 对象
print(s2) # cafe
- 在讨论编码和解码的问题之前,有必要先来介绍一下二进制序列类型。
2. 字节概要
- Python 内置了两种基本的二进制序列类型:Python 3 引入的不可变
bytes
类型和 Python 2.6 添加的可变bytearray
类型。 - bytes 或 bytearray 对象的各个元素是介于 0~255(含)之间的整数,而不像 Python 2 的 str 对象那样是单个的字符。然而,二进制序列的切片始终是同一类型的二进制序列,包括长度为 1 的切片。如下示例 2 :
'''
示例 2 bytes 和 bytearray 对象
'''
if __name__ == '__main__':
# bytes 对象可以从 str 对象使用给定的编码构建
cafe = bytes('cafe', encoding='utf-8')
# 各个元素是 range(256) 内的整数
print(cafe[0]) # 99
# bytes 对象的切片还是 bytes 对象,即使是只有一个字节的切片
print(cafe[:1]) # b'c'
cafe_arr = bytearray(cafe)
# bytearray 对象没有字面量句法,而是以 bytearray() 和字节序列字面量参数的形式显示。
print(cafe_arr) # bytearray(b'cafe')
# bytearray 对象的切片还是 bytearray 对象。
print(cafe_arr[-1:]) # bytearray(b'e')
- 除了格式化方法(format 和 format_map)和几个处理 Unicode 数据的方法(包括casefold、isdecimal、isidentifier、isnumeric、isprintable和 encode)之外,str 类型的其他方法都支持 bytes 和 bytearray类型。这意味着,我们可以使用熟悉的字符串方法处理二进制序列,如endswith、replace、strip、translate、upper 等。
- 二进制序列有个类方法是 str 没有的,名为 fromhex,它的作用是解析十六进制数字对(数字对之间的空格是可选的),构建二进制序列:
hex = bytes.fromhex('31 4B CE A9')
print(hex) # b'1K\xce\xa9'
- 构建 bytes 或 bytearray 实例还可以调用各自的构造方法,传入下述参数:
- 一个 str 对象和一个 encoding 关键字参数。
- 一个可迭代对象,提供 0~255 之间的数值。
- 一个实现了缓冲协议的对象(如bytes、bytearray、memoryview、array.array);此时,把源对象中的字节序列复制到新建的二进制序列中。
- 使用缓冲类对象构建二进制序列是一种低层操作,可能涉及类型转换。
'''
示例 3 使用数组中的原始数据初始化 bytes 对象
'''
import array
if __name__ == '__main__':
# 指定类型代码 h,创建一个短整数(16 位)数组。
numbers = array.array('h', [-2, -1, 0, 1, 2])
# b 保存组成 numbers 的字节序列的副本。
b = bytes(numbers)
# 5 个短整数的 10 个字节
print(b) # b'\xfe\xff\xff\xff\x00\x00\x01\x00\x02\x00'
- 使用缓冲类对象创建 bytes 或 bytearray 对象时,始终复制源对象中的字节序列。与之相反,memoryview 对象允许在二进制数据结构之间共享内存。如果想从二进制序列中提取结构化信息,struct 模块是重要的工具。下一节会使用这个模块处理 bytes 和 memoryview 对象。
2.1 结构体和内存视图
- struct 模块提供了一些函数,把打包的字节序列转换成不同类型字段组成的元组,还有一些函数用于执行反向转换,把元组转换成打包的字节序列。struct 模块能处理 bytes、bytearray 和 memoryview对象。
- memoryview 类不是用于创建或存储字节序列的,而是共享内存,让你访问其他二进制序列、打包的数组和缓冲中的数据切片,而无需复制字节序列。
import struct
'''
使用 memoryview 和 struct 查看一个 GIF 图像的首部
注意,memoryview 对象的切片是一个新 memoryview 对象,而且不会复制字节序列
'''
if __name__ == '__main__':
# 结构体的格式:< 是小字节序,3s3s 是两个 3 字节序列,HH 是两个16 位二进制整数。
fmt = '<3s3sHH'
with open("a.gif", 'rb') as fp:
# 使用内存中的文件内容创建一个 memoryview 对象
img = memoryview(fp.read())
print(img) # <memory at 0x0000023603A37D00>
# 使用切片再创建一个 memoryview 对象,这里不会复制字节序列
header = img[:10]
print(header) # <memory at 0x000001D2EACE7880>
print(bytes(header)) # b'GIF89a \x03X\x02'
# 拆包 memoryview 对象,得到一个元组,包含类型、版本、宽度和高度
unpack = struct.unpack(fmt, bytes(header))
print(unpack) # (b'GIF', b'89a', 800, 600)
del header
del img
- 简要探讨 Python 的二进制序列类型之后,下面说明如何在它们和字符串之间转换。
3. 基本的编解码器
- Python 自带了超过 100 种编解码器(codec, encoder/decoder),用于在文本和字节之间相互转换。每个编解码器都有一个名称,如’utf_8’,而且经常有几个别名,如 ‘utf8’、‘utf-8’ 和 ‘U8’。这些名称可以传给 open()、str.encode()、bytes.decode() 等函数的 encoding 参数。如下示例:
'''
用 3 个编解码器把相同的文本编码成不同的字节序列
'''
if __name__ == '__main__':
for code in ['latin_1', 'utf_8', 'utf_16']:
print(code, 'El Niño'.encode(code), 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'
- 一些典型编码,介绍如下:
latin1(即 iso8859_1)
:一种重要的编码,是其他编码的基础,例如 cp1252 和Unicode(注意,latin1 与 cp1252 的字节值是一样的,甚至连码位也相同)。cp1252
:Microsoft 制定的 latin1 超集,添加了有用的符号,例如弯引号和€(欧元);有些 Windows 应用把它称为“ANSI”,但它并不是 ANSI标准。cp437
:IBM PC 最初的字符集,包含框图符号。与后来出现的 latin1 不兼容。gb2312
:用于编码简体中文的陈旧标准;这是亚洲语言中使用较广泛的多字节编码之一。utf-8
:目前 Web 中最常见的 8 位编码; 与 ASCII 兼容(纯 ASCII 文本是有效的 UTF-8 文本)。utf-16le
:UTF-16 的 16 位编码方案的一种形式;所有 UTF-16 支持通过转义序列(称为“代理对”,surrogate pair)表示超过 U+FFFF 的码位。
- 概述常规的编码之后,下面要处理编码和解码过程中存在的问题。
4. 了解编解码问题
- 虽然有个一般性的
UnicodeError
异常,但是报告错误时几乎都会指明具体的异常:UnicodeEncodeError
(把字符串转换成二进制序列时)或UnicodeDecodeError
(把二进制序列转换成字符串时)。如果源码的编码与预期不符,加载 Python 模块时还可能抛出SyntaxError
。接下来的几节说明如何处理这些错误。
4.1 处理UnicodeEncodeError
- 多数非 UTF 编解码器只能处理 Unicode 字符的一小部分子集。把文本转换成字节序列时,如果目标编码中没有定义某个字符,那就会抛出
UnicodeEncodeError
异常,除非把 errors 参数传给编码方法或函数,对错误进行特殊处理。如下示例:
'''
编码成字节序列:成功和 UnicodeEncodeError 错误处理
error='ignore' 处理方式悄无声息地跳过无法编码的字符;这样做通常很是不妥
编码时指定 error='replace',把无法编码的字符替换成 '?';数据损坏了,但是用户知道出了问题。
'xmlcharrefreplace' 把无法编码的字符替换成 XML 实体。
'''
if __name__ == '__main__':
city = 'São Paulo'
c_utf8 = city.encode('utf-8')
c_utf16 = city.encode('utf-16')
c_iso = city.encode('iso8859_1')
print(c_utf8) # b'S\xc3\xa3o Paulo'
print(c_utf16) # b'\xff\xfeS\x00\xe3\x00o\x00 \x00P\x00a\x00u\x00l\x00o\x00'
print(c_iso) # b'S\xe3o Paulo'
try:
c_cp437 = city.encode('cp437')
print(c_cp437)
except UnicodeEncodeError as e:
print(e) # 'charmap' codec can't encode character '\xe3' in position 1: character maps to <undefined>
c_cp437_ignore = city.encode(encoding='cp437', errors='ignore')
c_cp437_replace = city.encode(encoding='cp437', errors='replace')
c_cp437_xcr = city.encode(encoding='cp437', errors='xmlcharrefreplace')
print(c_cp437_ignore) # b'So Paulo'
print(c_cp437_replace) # b'S?o Paulo'
print(c_cp437_xcr) # b'São Paulo'
4.2 处理UnicodeDecodeError
- 不是每一个字节都包含有效的 ASCII 字符,也不是每一个字符序列都是有效的 UTF-8 或 UTF-16。因此,把二进制序列转换成文本时,如果假设是这两个编码中的一个,遇到无法转换的字节序列时会抛出
UnicodeDecodeError
。 - 另一方面,很多陈旧的 8 位编码——如 ‘cp1252’、‘iso8859_1’ 和’koi8_r’——能解码任何字节序列流而不抛出错误,例如随机噪声。因此,如果程序使用错误的 8 位编码,解码过程悄无声息,而得到的是无用输出。如下示例:
'''
把字节序列解码成字符串:成功和 UnicodeDecodeError 错误处理
'''
if __name__ == '__main__':
octets = b'Montr\xe9al'
d_cp1252 = octets.decode(encoding='cp1252')
d_iso = octets.decode(encoding='iso8859_7')
d_koi8 = octets.decode(encoding='koi8_r')
print(d_cp1252) # Montréal
print(d_iso) # Montrιal
print(d_koi8) # MontrИal
try:
# 'utf_8' 编解码器检测到 octets 不是有效的 UTF-8 字符串,抛出 UnicodeDecodeError。
d_utf8 = octets.decode(encoding='utf-8')
print(d_utf8)
except UnicodeDecodeError as e:
print(e) # 'utf-8' codec can't decode byte 0xe9 in position 5: invalid continuation byte
d_utf8_replace = octets.decode(encoding='utf-8', errors="replace")
print(d_utf8_replace) # Montr�al
4.3 使用预期之外的编码加载模块时抛出的 SyntaxError
- Python 3 默认使用 UTF-8 编码源码。如果加载的 .py 模块中包含 UTF-8 之外的数据,而且没有声明编码,会得到如下报错:
SyntaxError: Non-UTF-8 code starting with '\xe1' in file ola.py on line1, but no encoding declared; see http://python.org/dev/peps/pep-0263/for details
- 为了修正这个问题,可以在文件顶部添加一个神奇的 coding 注释,如:
# coding: cp1252
'''
“你好,世界!”的葡萄牙语版
'''
print('Olá, Mundo!')
- 现在,Python 3 的源码不再限于使用 ASCII,而是默认使用优秀的
UTF-8
编码,因此要修正源码的陈旧编码(如 ‘cp1252’)问题,最好将其转换成 UTF-8,别去麻烦 coding 注释。如果你用的编辑器不支持 UTF-8,那么是时候换一个了。 - 假如有个文本文件,里面保存的是源码或诗句,但是你不知道它的编码。如何查明真正的编码呢?
4.4 如何找出字节序列的编码
- 如何找出字节序列的编码?简单来说,不能。必须有人告诉你。
- 就像人类语言也有规则和限制一样,只要假定字节流是人类可读的纯文本,就可能通过试探和分析找出编码。例如,如果
b'\x00'
字节经常出现,那么可能是 16 位或 32 位编码,而不是 8 位编码方案,因为纯文本中不能包含空字符。 - 统一字符编码侦测包
Chardet
(https://pypi.python.org/pypi/chardet)就是这样工作的,它能识别所支持的 30 种编码。Chardet 是一个 Python 库,可以在程序中使用,不过它也提供了命令行工具chardetect
。如下示例使用chardetect
命令行识别本地的app.py文件编码:
$ chardetect app.py
app.py: ascii with confidence 1.0
4.5 BOM:有用的鬼符
- 你可能注意到了,UTF-16 编码的序列开头有几个额外的字节,如下所示:
if __name__ == '__main__':
u16 = 'El Niño'.encode('utf_16')
print(u16) # b'\xff\xfeE\x00l\x00 \x00N\x00i\x00\xf1\x00o\x00'
- 我指的是
b'\xff\xfe'
。这是 BOM,即字节序标记(byte-ordermark),指明编码时使用 Intel CPU 的小字节序。 - 在小字节序设备中,各个码位的最低有效字节在前面:字母 ‘E’ 的码位是 U+0045(十进制数 69),在字节偏移的第 2 位和第 3 位编码为 69和 0。
print(list(u16)) # [255, 254, 69, 0, 108, 0, 32, 0, 78, 0, 105, 0, 241, 0, 111, 0]
- 在大字节序 CPU 中,编码顺序是相反的;‘E’ 编码为 0 和 69。
- 为了避免混淆,UTF-16 编码在要编码的文本前面加上特殊的不可见字符
ZERO WIDTH NO-BREAK SPACE(U+FEFF)
。在小字节序系统中,这个字符编码为 b’\xff\xfe’(十进制数 255, 254)。因为按照设计,U+FFFE 字符不存在,在小字节序编码中,字节序列b’\xff\xfe’ 必定是 ZERO WIDTH NO-BREAK SPACE,所以编解码器知道该用哪个字节序。 - UTF-16 有两个变种:
UTF-16LE
,显式指明使用小字节序;UTF-16BE
,显式指明使用大字节序。如果使用这两个变种,不会生成 BOM:
u16le = 'El Niño'.encode('utf_16le')
u16be = 'El Niño'.encode('utf_16be')
print(u16le) # b'E\x00l\x00 \x00N\x00i\x00\xf1\x00o\x00'
print(list(u16le)) # [69, 0, 108, 0, 32, 0, 78, 0, 105, 0, 241, 0, 111, 0]
print(u16be) # b'\x00E\x00l\x00 \x00N\x00i\x00\xf1\x00o'
print(list(u16be)) # [0, 69, 0, 108, 0, 32, 0, 78, 0, 105, 0, 241, 0, 111]
- 如果有 BOM,UTF-16 编解码器会将其过滤掉,为你提供没有前导ZERO WIDTH NO-BREAK SPACE 字符的真正文本。
- 与字节序有关的问题只对一个字(word)占多个字节的编码(如 UTF-16 和 UTF-32)有影响。UTF-8 的一大优势是,不管设备使用哪种字节序,生成的字节序列始终一致,因此不需要 BOM。
5. 处理文本文件
- 处理文本的最佳实践是“Unicode 三明治”(如图 所示)。 意思是,
要尽早把输入(例如读取文件时)的字节序列解码成字符串
。这种三明治中的“肉片”是程序的业务逻辑,在这里只能处理字符串对象。在其他处理过程中,一定不能编码或解码。对输出来说,则要尽量晚地把字符串编码成字节序列
。多数 Web 框架都是这样做的,使用框架时很少接触字节序列。例如,在 Django 中,视图应该输出 Unicode 字符串;Django 会负责把响应编码成字节序列,而且默认使用 UTF-8 编码。
- 内置的
open
函数会在读取文件时做必要的解码,以文本模式写入文件时还会做必要的编码,所以调用my_file.read()
方法得到的以及传给my_file.write(text)
方法的都是字符串对象。 - 可以看出,处理文本文件很简单。但是,如果依赖默认编码,你会遇到麻烦。
# -*- encoding: utf-8 -*-
'''
一个平台上的编码问题(如果在你的机器上运行,它可能会发生,也可能不会)
'''
if __name__ == '__main__':
open("cafe.txt", "w", encoding='utf-8').write("café")
print(open('cafe.txt').read()) # caf茅
问题是:写入文件时指定了 UTF-8 编码,但是读取文件时没有这么做,因此 Python 假定要使用系统默认的编码,于是文件的最后一个字节解码成了字符 ‘茅’,而不是 ‘é’。
- 所以最好不用使用默认编码,除非你确定默认编码是你最终想要的编码
print(open('cafe.txt', encoding='utf-8').read()) # café
- 探索编码默认值:
import locale, sys
if __name__ == '__main__':
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))
- output:
locale.getpreferredencoding() -> 'cp936'
type(my_file) -> <class '_io.TextIOWrapper'>
my_file.encoding -> 'cp936'
sys.stdout.isatty() -> False
sys.stdout.encoding -> 'utf-8'
sys.stdin.isatty() -> False
sys.stdin.encoding -> 'utf-8'
sys.stderr.isatty() -> False
sys.stderr.encoding -> 'utf-8'
sys.getdefaultencoding() -> 'utf-8'
sys.getfilesystemencoding() -> 'utf-8'
- 因此,关于编码默认值的最佳建议是:
别依赖默认值
6. 为了正确比较而规范化Unicode字符串
- 因为 Unicode 有组合字符(变音符号和附加到前一个字符上的记号,打印时作为一个整体),所以字符串比较起来很复杂。
- 例如,“café”这个词可以使用两种方式构成,分别有 4 个和 5 个码位,但是结果完全一样:
if __name__ == '__main__':
s1 = 'café'
s2 = 'cafe\u0301'
print(s1, s2) # café café́
print(len(s1), len(s2)) # 4 5
print(s1 == s2) # False
- U+0301 是 COMBINING ACUTE ACCENT,加在“e”后面得到“é”。在Unicode 标准中,‘é’ 和 ‘e\u0301’ 这样的序列叫“
标准等价物
”,应用程序应该把它们视作相同的字符。但是,Python 看到的是不同的码位序列,因此判定二者不相等。 - 这个问题的解决方案是使用
unicodedata.normalize
函数提供的Unicode
规范化。该函数的第一个参数是这 4 个字符串其中一个:'NFC'、'NFD'、'NFKC' 和 'NFKD'
。下面先说明前两个。 - NFC(Normalization Form C)使用最少的码位构成等价的字符串,而NFD 把组合字符分解成基字符和单独的组合字符。这两种规范化方式都能让比较行为符合预期:
from unicodedata import normalize
if __name__ == '__main__':
s1 = 'café'
# 分解成"e"和重音符
s2 = 'cafe\u0301'
print(len(s1), len(s2))
print(len(normalize('NFC', s1)), len(normalize('NFC', s2)))
print(len(normalize('NFD', s1)), len(normalize('NFD', s2)))
print(normalize('NFC', s1) == normalize('NFC', s2))
print(normalize('NFD', s1) == normalize('NFD', s2))
# 4 5
# 4 4
# 5 5
# True
# True
- 西方键盘通常能输出组合字符,因此用户输入的文本默认是 NFC 形式。不过,安全起见,保存文本之前,最好使用 normalize(‘NFC’,user_text) 清洗字符串。
- 使用 NFC 时,有些单字符会被规范成另一个单字符。例如,电阻的单位欧姆(Ω)会被规范成希腊字母大写的欧米加。这两个字符在视觉上是一样的,但是比较时并不相等,因此要规范化,防止出现意外:
from unicodedata import normalize, name
if __name__ == '__main__':
ohm = '\u2126'
ohm_c = normalize("NFC", ohm)
print(name(ohm))
print(name(ohm_c))
print(ohm == ohm_c)
print(normalize("NFC", ohm) == normalize("NFC", ohm_c))
# OHM SIGN
# GREEK CAPITAL LETTER OMEGA
# False
# True
- 在另外两个规范化形式(NFKC 和 NFKD)的首字母缩略词中,字母 K表示“compatibility”(兼容性)。这两种是较严格的规范化形式,对
“兼容字符”
有影响。 - 虽然 Unicode 的目标是为各个字符提供“规范的”码位,但是为了兼容现有的标准,有些字符会出现多次。例如,虽然希腊字母表中有“μ”这个字母(码位是 U+03BC,GREEK SMALL LETTERMU),但是 Unicode 还是加入了微符号’µ’(U+00B5),以便与latin1 相互转换。因此,微符号是一个“兼容字符”。
- 在 NFKC 和 NFKD 形式中,各个兼容字符会被替换成一个或多个“兼容分解”字符,即便这样有些格式损失,但仍是“首选”表述。二分之一 ‘½’(U+00BD)经过兼容分解后得到的是三个字符序列’1/2’。
from unicodedata import normalize
if __name__ == '__main__':
half = '½'
four_squared = '4²'
micro = 'μ'
print(normalize("NFKC", half))
print(normalize("NFKC", four_squared))
print(micro, normalize("NFKC", micro))
# 1⁄2
# 42
# μ μ
- 因此,NFKC 或 NFKD 可能会损失或曲解信息,但是可以为搜索和索引提供便利的中间表述:用户搜索 ‘1 / 2 inch’ 时,如果还能找到包含 ‘½ inch’ 的文档,那么用户会感到满意。
- 为搜索或索引准备文本时,还有一个有用的操作,即下一节讨论的大小写折叠。
6.1 大小写折叠
- 大小写折叠其实就是把所有文本变成小写,再做些其他转换。这个功能由
str.casefold()
方法(Python 3.3 +)支持。 - 对于只包含 latin1 字符的字符串 s,s.casefold() 得到的结果与s.lower() 一样,只有极个别例外。自 Python 3.4 起,str.casefold() 和 str.lower() 得到不同结果的有 116 个码位。Unicode 6.3 命名了 110 122 个字符,这只占 0.11%。
- 接下来的几节将使用这些规范化知识来开发几个实用的函数。
6.2 规范化文本匹配实用函数
由前文可知,NFC 和 NFD 可以放心使用,而且能合理比较 Unicode 字符串。对大多数应用来说,NFC 是最好的规范化形式。不区分大小写的比较应该使用 str.casefold()。
- 如果要处理多语言文本,可以使用 nfc_equal和 fold_equal 函数。示例如下:
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()
if __name__ == '__main__':
s1 = 'café'
s2 = 'cafe\u0301'
s3 = 'Straße'
s4 = 'strasse'
print(s1 == s2) # False
print(nfc_equal(s1, s2)) # True
print(nfc_equal("A", 'a')) # False
print(s3 == s4) # False
print(nfc_equal(s3, s4)) # False
print(fold_equal(s3, s4)) # True
print(fold_equal("A", "a")) # True
6.3 极端“规范化”:去掉变音符号
- Google 搜索涉及很多技术,其中一个显然是
忽略变音符号
(如重音符、下加符等)。去掉变音符号不是正确的规范化方式,因为这往往会改变词的意思,而且可能误判搜索结果。但是对现实生活却有所帮助:人们有时很懒,或者不知道怎么正确使用变音符号,而且拼写规则会随时间变化,因此实际语言中的重音经常变来变去。除了搜索,去掉变音符号还能让 URL 更易于阅读。 - 面是维基百科中介绍圣保罗市(São Paulo)的文章的URL:
http://en.wikipedia.org/wiki/S%C3%A3o_Paulo
- 其中,“%C3%A3”是 UTF-8 编码“ã”字母(带有波形符的“a”)转义后得到的结果。下述形式更友好,尽管拼写是错误的:
http://en.wikipedia.org/wiki/Sao_Paulo
- 如果想把字符串中的所有变音符号都去掉, 可以采用如下函数:
from unicodedata import normalize, combining
def shave_marks(txt):
"""去掉全部变音符号"""
# 把所有字符分解成基字符和组合记号。
norm_txt = normalize("NFD", txt)
# 过滤掉所有组合记号
shaved = "".join(c for c in norm_txt if not combining(c))
# 重组所有字符
return normalize("NFC", shaved)
if __name__ == '__main__':
order = '“Herr Voß: • ½ cup of OEtker™ caffè latte • bowl of açaí.”'
Greek = 'Zέφupoς, Zéfiro'
print(shave_marks(order))
print(shave_marks(Greek))
# “Herr Voß: • ½ cup of OEtker™ caffe latte • bowl of acai.”
# Zεφupoς, Zefiro
- 通常,去掉变音符号是为了把拉丁文本变成纯粹的 ASCII,但是 shave_marks 函数还会修改非拉丁字符(如希腊字母),而只去掉重音符并不能把它们变成 ASCII 字符。因此,我们应该分析各个基字符,仅当字符在拉丁字母表中时才删除附加的记号,如下示例:
from unicodedata import normalize, combining
from string import ascii_letters
def shave_marks_latin(txt):
"""把拉丁基字符中所有的变音符号删除"""
norm_txt = normalize("NFD", txt)
Latin_base = False
keepers = []
for c in norm_txt:
if combining(c) and Latin_base:
continue # 忽略拉丁基字符上的变音符号
keepers.append(c)
# 如果不是组合字符,那就是新的基字符
if not combining(c):
Latin_base = c in ascii_letters
shaved = ''.join(keepers)
return normalize("NFC", shaved)
- 不同语言删除变音符号的规则也有所不同。例如,德语把’ü’ 变成 ‘ue’。只有知道目标语言、目标用户群和转换后的用途,才能确定要不要做这么深入的规范化。
- 接下来要解决的 Unicode 问题是……排序
7. Unicode文本排序
- Python 比较任何类型的序列时,会一一比较序列里的各个元素。对字符串来说,比较的是码位。可是在比较非 ASCII 字符时,得到的结果不尽如人意。
- 下面对一个生长在巴西的水果的列表进行排序:
if __name__ == '__main__':
fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
print(sorted(fruits)) # ['acerola', 'atemoia', 'açaí', 'caju', 'cajá']
- 不同的区域采用的排序规则有所不同,葡萄牙语等很多语言按照拉丁字母表排序,重音符号和下加符对排序几乎没什么影响。因此,排序时“cajá”视作“caja”,应该排在“caju”前面。
- 在 Python 中,非 ASCII 文本的标准排序方式是使用
locale.strxfrm
,这 个函数会把字符串转换成适合所在区域进行比较的形式”。 - 使用 locale.strxfrm 函数之前,必须先为应用设定合适的区域设置,还要保证操作系统支持这项设置。区域设为 pt_BR 的GNU/Linux(Ubuntu 14.04)使用示例:
import locale
if __name__ == '__main__':
fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
print(sorted(fruits))
locale.setlocale(locale.LC_COLLATE, 'pt_BR.UTF-8')
# 使用 locale.strxfrm 函数做排序键
print(sorted(fruits, key=locale.strxfrm))
# ['acerola', 'atemoia', 'açaí', 'caju', 'cajá']
# ['açaí', 'acerola', 'atemoia', 'cajá', 'caju']
- 使用 setlocale 会有诸多限制,区域设置是全局的,使用 setlocale 就显得不太合适。而且操作系统必须支持此设置,还要知道如何拼区域名称。标准库提供的国际化排序方案可用,但是似乎只支持GNU/Linux(可能也支持 Windows,但你得是专家)。即便如此,还要依赖区域设置,而这会为部署带来问题。
- 幸好,有个较为简单的方案:
PyPI 中的 PyUCA 库
。
使用Unicode排序算法排序
- James Tauber,一位高产的 Django 贡献者,开发了 PyUCA 库。这是Unicode 排序算法(Unicode Collation Algorithm,UCA)的纯 Python 实现。如下示例展示了它的简单用法:
import pyuca
if __name__ == '__main__':
coll = pyuca.Collator()
fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
print(sorted(fruits, key=coll.sort_key))
# ['açaí', 'acerola', 'atemoia', 'cajá', 'caju']
8. Unicode数据库
- Unicode 标准提供了一个完整的数据库(许多格式化的文本文件),不仅包括码位与字符名称之间的映射,还有各个字符的元数据,以及字符之间的关系。例如,Unicode 数据库记录了字符是否可以打印、是不是字母、是不是数字,或者是不是其他数值符号。字符串的isidentifier、isprintable、isdecimal 和 isnumeric 等方法就是靠这些信息作判断的。 str.casefold 方法也用到了 Unicode表中的信息。
- unicodedata 模块中有几个函数用于获取字符的元数据。示例展示了
unicodedata.name() 和 unicodedata.numeric() 函数,以及字符串的 .isdecimal() 和 .isnumeric() 方法的用法。
import unicodedata, re
if __name__ == '__main__':
re_digit = re.compile(r'\d')
sample = '1\xbc\xb2\u0969\u136b\u216b\u2466\u2480\u3285'
for char in sample:
print(
# U+0000 格式的码位。
'U+%04x' % ord(char),
# 在长度为 6 的字符串中居中显示字符。
char.center(6),
# 如果字符匹配正则表达式 r'\d',显示 re_dig。
're_dig' if re_digit.match(char) else '-',
# 如果 char.isdigit() 返回 True,显示 isdig。
'isdig' if char.isdigit() else '-',
# 如果 char.isnumeric() 返回 True,显示 isnum。
'isnum' if char.isnumeric() else '-',
# 使用长度为 5、小数点后保留 2 位的浮点数显示数值。
format(unicodedata.numeric(char), '5.2f'),
# Unicode 标准中字符的名称。
unicodedata.name(char),
sep='\t'
)
- output:
U+0031 1 re_dig isdig isnum 1.00 DIGIT ONE
U+00bc ¼ - - isnum 0.25 VULGAR FRACTION ONE QUARTER
U+00b2 ² - isdig isnum 2.00 SUPERSCRIPT TWO
U+0969 ३ re_dig isdig isnum 3.00 DEVANAGARI DIGIT THREE
U+136b ፫ - isdig isnum 3.00 ETHIOPIC DIGIT THREE
U+216b Ⅻ - - isnum 12.00 ROMAN NUMERAL TWELVE
U+2466 ⑦ - isdig isnum 7.00 CIRCLED DIGIT SEVEN
U+2480 ⒀ - - isnum 13.00 PARENTHESIZED NUMBER THIRTEEN
U+3285 ㊅ - - isnum 6.00 CIRCLED IDEOGRAPH SIX
- 结果表明,Unicode 知道表示数字的符号的数值。正则表达式 r’\d’ 能匹配数字“1”和梵文数字 3,但是不能匹配 isdigit 方法判断为数字的其他字符。re 模块对 Unicode 的支持并不充分。PyPI 中有个新开发的
regex
模块,它的最终目的是取代re 模块,以提供更好的 Unicode 支持。 - 在结束对字符串和字节序列的讨论之前,我们还要简要说明一个新的趋势——双模式 API,即提供的函数能接受字符串或字节序列为参数,然后根据类型进行特殊处理。
9. 支持字符串和字节序列的双模式API
- 标准库中的一些函数能接受字符串或字节序列为参数,然后根据类型展
现不同的行为。re 和 os 模块中就有这样的函数。 - 如果使用字节序列构建正则表达式,\d 和 \w 等模式只能匹配 ASCII 字符;相比之下,如果是字符串模式,就能匹配 ASCII 之外的 Unicode 数字或字母。
- 比较简单的字符串正则表达式和字节序列正则表达式的行为:
import re
if __name__ == '__main__':
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+')
txt_str = ("Ramanujan saw \u0be7\u0bed\u0be8\u0bef as 1729 = 1³ + 12³ = 9³ + 10³.")
txt_bytes = txt_str.encode("utf-8")
print('Text', repr(txt_str), sep="\n ")
print("Numbers")
print(" str ", re_numbers_str.findall(txt_str))
print(" bytes ", re_numbers_bytes.findall(txt_bytes))
print("Words")
print(" str ", re_words_str.findall(txt_str))
print(" bytes ", re_words_bytes.findall(txt_bytes))
- output:
Text
'Ramanujan saw ௧௭௨௯ as 1729 = 1³ + 12³ = 9³ + 10³.'
Numbers
str ['௧௭௨௯', '1729', '1', '12', '9', '10']
bytes [b'1729', b'1', b'12', b'9', b'10']
Words
str ['Ramanujan', 'saw', '௧௭௨௯', 'as', '1729', '1³', '12³', '9³', '10³']
bytes [b'Ramanujan', b'saw', b'as', b'1729', b'1', b'12', b'9', b'10']
- 示例说明了一个问题:可以使用正则表达式搜索字符串和字节序列,但是在后一种情况中,ASCII 范围外的字节不会当成数字和组成单词的字母。字符串正则表达式有个 re.ASCII 标志,它让\w、\W、\b、\B、\d、\D、\s 和 \S 只匹配 ASCII 字符。
- 另一个重要的双模式模块是 os。
os函数中的字符串和字节序列
- GNU/Linux 内核不理解 Unicode,因此你可能发现了,对任何合理的编码方案来说,在文件名中使用字节序列都是无效的,无法解码成字符串。在不同操作系统中使用各种客户端的文件服务器,在遇到这个问题时尤其容易出错。
- 为了规避这个问题,os 模块中的所有函数、文件名或路径名参数既能使用字符串,也能使用字节序列。如果这样的函数使用字符串参数调用,该参数会使用 sys.getfilesystemencoding() 得到的编解码器自动编码,然后操作系统会使用相同的编解码器解码。这几乎就是我们想要的行为,与 Unicode 三明治最佳实践一致。
- 但是,如果必须处理(也可能是修正)那些无法使用上述方式自动处理的文件名,可以
把字节序列参数传给 os 模块中的函数,得到字节序列返回值
。这一特性允许我们处理任何文件名或路径名,不管里面有多少鬼符 - 字符串和字节序列参数传给 listdir 函数得到的结果:
# 获取当前目录所有文件名,组成列表
os.listdir('.')
# 获取当前目录所有文件名的二进制形式,组成列表
os.listdir(b'.')
fsencode(filename)
如果 filename 是 str 类型(此外还可能是 bytes 类型),使用
sys.getfilesystemencoding()
返回的编解码器把 filename 编码成字节序列;否则,返回未经修改的 filename 字节序列。fsdecode(filename)
如果 filename 是 bytes 类型(此外还可能是 str 类型),使用
sys.getfilesystemencoding() 返回的编解码器把 filename 解码成字符串;否则,返回未经修改的 filename 字符串。