FluentPythonCh04

 5th July 2017 at 10:07am

这章主要讲字符串处理。

PyCon 2014 有个 slide 讲得非常好,比之前的 UniPain 更简单明了。放在 Dropbox/Journey/Resources/Python/character-encoding-and-unicode.pdf。它也有一个对应的 视频

Character Issues

“字符串”(string)是一个由字符(character)组成的序列。但是重点是,字符(character)是指什么。

Unicode 标准的基本理念是,将标识一个字符是什么,从字符的字节表示(byte representation)中脱离出来。

  • 字符的标识在 Unicode 标准中叫 Unicode code point。code point (码点?)是一个数字,范围在 0 到 1114111 (Unicode 6.3)。每个字符都有一个唯一的 Unicode code point。
  • 字符的最终字节表示,取决于具体使用了什么编码。

Python 3 有两个表示字符串的基础类型,一个是 str,表示 Unicode 字符串;一个是 bytes,表示被编码(encode)过的字符串。

Byte Essentials

>>> cafe = bytes('café', encoding='utf_8')
>>> cafe
b'caf\xc3\xa9'
>>> cafe[0]
99
>>> cafe[:1]
b'c'
>>> cafe_arr = bytearray(cafe)
>>> cafe_arr
bytearray(b'caf\xc3\xa9')
>>> cafe_err[0]
99
>>> cafe_arr[-1:]
bytearray(b'\xa9')

取下标和切片方面,有一些细节值得注意:

  • bytesbytearray 类型,取下标操作(cafe[0])返回的是一个 0~255 的整数;取切片(cafe[:1])时,返回的结果还是原类型,即 bytesbytearray
  • str 类型比较特殊,取下标和取切片都是返回 str 类型
  • str 类型是 Python 的内置序列类型中,s[0] == s[:1] 唯一成立的

对于 bytesbytearrayprint 的结果,里面即有 ASCII 字符也有 \x 的 16 进制表示。本来 bytes 表示的是一段字符序列,全部用 16 进制显示出来也不过份,但是这里应该是照顾了可读性,所以将 ASCII 字符打印出来了。规则如下:

  1. 如果字符是可打印的 ASCII 字符,以 ASCII 字符的形式显示
  2. 如果不是可打印的,但是是 \ 可转义的(如 \n, \t),以这种形式显示
  3. 其他字符以 \xXX 的 16 进制形式出现

str 类型的 print 结果均是显示字符的 Unicode 字符表示。比如 é 还是以 é 显示。但是这个 print 结果最终还是打到终端出来,所以终端的编码对它有影响。比如我的终端使用的是 UTF-8 编码,如果有一些 Unicode 字符没有对应的 UTF-8 编码,那么应该会展示成 \uXXXX 的形式。

实现了 buffer protocol 的类型有 bytes, bytearray, memoryview, array.array

从一个实现了 buffer protocol 的对象初始化 bytes / bytearray 时总会有数据拷贝;而用同样的方式初始化 memoryview 时则不会有。

Structs and Memory Views

Structs 是用来做 Python 数据结构跟 C/C++ Struct 之间的相互转换的。书中的这个示例代码很好,用了 memoryview 去取前十个字节:

>>> import struct
>>> fmt = '<3s3sHH'
>>> with open('filter.gif', 'rb') as fp:
...     img = memoryview(fp.read())
...
>>> header = img[:10]
>>> bytes(header)
b'GIF89a+\x02\xe6\x00'
>>> struct.unpack(fmt, header)
(b'GIF', b'89a', 555, 230)
>>> del header
>>> del img

Basic Encoders/Decoders

这个图可以看出,有些编码格式(如 ascii, gb2312)并不能编码全部的字符,这也是产生 UnicodeEncodeError / UnicodeDecodeError 的主要原因。Python 提供了一些参数,用来指定编解码失败时的行为:

>>> city = 'São Paulo'
>>> city.encode('utf_8')  
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')  
b'S\xe3o Paulo'
>>> city.encode('cp437')  
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')  
b'S&#227;o Paulo'

而且这些行为还可以通过 `codecs.register_error` 来扩展。

How to Discover the Encoding of a Byte Sequence

怎样知道一串字节序列的编码呢?答案是:你没办法知道,除非有人告诉你。

但是不同的编码类型编出来的内容各有特点,所以可以大致推测出某段字节序列的编码。Chardet 库 做的就是这个事情。

BeautifulSoup4 中提供了 UnicodeDammit 库,也是用来猜测编码类型的。实现原理似乎跟 Chardet 不一样,也可以参考一下。

BOM: A Useful Gremlin

UTF-16 编码在这上面的处理有点意思。它可以编成 Big Endian 也可以编成 Little Endian。对于 Big Endian,它编码出来的字节序列会在前面加上 ZERO WIDTH NO-BREAK SPACE (U+FEFF) \xFEFF;对于 Little Endian,会在前面加上 \xFFFE。但是 Unicode 码表中是没有 U+FFFE 的,所以程序遇到 UTF-16 字节序列时,可以看看前两个字节是 \xFEFF 还是 \xFFFE,来判断是大端小端。

Encoding Defaults: A Madhouse

这部分值得一看。说的是各种情况下 Python 用的默认字符编码。

结论是:

  • 打开 (Linux API open) 文件,或者 stdin / stdout / stderr 重定向到文件时,默认用的编码是 locale.getpreferredencoding()
  • stdin / stdout / stderr 如果没有重定向到文件,且 PYTHONIOENCODING 环境变量被指定时,默认编码使用环境变量定义的;如果环境变量未指定,则使用的编码继承自你的控制台(这里细节还没弄清楚)
  • bytes <=> str 的隐式转换,默认用的是 sys.getdefaultencoding() 的编码。但是据说 Python 3 很少有这类隐式转换。Python 2 就有很多
  • 如果传给 open 函数的文件名参数是个 str,那么它会被使用 sys.getfilesystemencoding() 所表示的编码转成 bytes,再传给系统 API。如果文件名参数是个 bytes,则不会有转换,直接传给系统 API

终级结论是:不要依赖默认的编码类型,总是显式地指定它。

但是就算你搞定了 Unicode,还有可怕的 Unicode Normalization (正规化)问题等着你。

Normalizing Unicode for Saner Comparisons

Unicode 里面有个叫 组合字符(combining characters)的东西,它使得字符串的比较变得麻烦:

>>> s1 = 'café'
>>> s2 = 'cafe\u0301'
>>> s1, s2
('café', 'café')
>>> len(s1), len(s2)
(4, 5)
>>> s1 == s2
False

\u0301 是个变音符号,ée\u0301 被称作 “canonical equivalents”。Unicode 规范中定义了 Unicode Normalization 来解决这个问题,Python 标准库中 `unicodedata.normalize` 是相应的实现。规范定义了四种 Normalization 的方法:NFC, NFD, NFKC, NFKD,每一种都可以解决字符串比较的问题:

>>> from unicodedata import normalize
>>> s1 = 'café'  # composed "e" with acute accent
>>> s2 = 'cafe\u0301'  # decomposed "e" and 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

Unicode 的 文档 描述了这几种 Normalization Form:

FormDescription
Normalization Form D (NFD)Canonical Decomposition
Normalization Form C (NFC) Canonical Decomposition, followed by Canonical Composition
Normalization Form KD (NFKD)Compatibility Decomposition
Normalization Form KC (NFKC)Compatibility Decomposition, followed by Canonical Composition

简单的说,NFD 把字符串分解成字符和组合字符,而 NFC 把字符和组合字符组成成最短的同义字符串。大部分西文键盘敲出来的字符是 NFC 形式的,而且 NFC 是 W3C 推荐的正规化形式

后面如果有需要解决这类问题的时候,W3C 这个文档似乎是个不错的参考,它有一个论证为何选择 NFC 的过程。

有些单个符号在 NFC 正规化后,会变成另外一个视觉上一致的符号。比如电阻的符号 Ω 会变成 Greek uppercase omega。

NFKD 和 NFKC 中的 K 表示 compatibility,因为他们会影响所谓的 compatibility characters。比如会把 ½ 转成 1/2 会被转成 42。这两种形式可能会影响字符串的显示格式。一般的应用场景,是在搜索和建数据索引时。不是很理解这两种形式存在的意义是什么。

Case Folding

Case folding 做是的把字符串转成小写,并且做一些额外的转换。它的应用场景,是做大小写无关的字符串比较。有一部分字符会被转成视觉上一致的其他符号,如 µMICRO SIGN 转成 GREEK SMALL LETTER MU

作者写了 一些代码 用来做字符串的规格化,而且还做了 IPython Notebook 页面,实在太良心了!

Sorting Unicode Text

Python 标准库 locale 对于地区相关的字符串排序做得并不好,依赖于 OS 实现和其他一些奇怪的条件。但是第三方库 PyUCA 是不错的解决方案。UCA 代表 Unicode Collation Algorithm,书里没细说它是个什么东西,有需要再细看。

The Unicode Database

Unicode 数据库包罗万像,它不仅知道一个符号和它对应的含义,还知道这个符号是不是能打印的、是不是数字、是不是组合字符等。Python 标准库 unicodedata 可以解读这些信息,下面的图是用一段 Python 程序生成的,最后两列分别是 unicodedata.numeric(char)unicodedata.name(char) 的结果:

Dual-Mode str and bytes APIs

在正则表达式中,str 跟 bytes 的表达式,在匹配时的行为不同。bytes 的正则表达式只会匹配 ASCII 字符,而 str 的会匹配各种 Unicode 字符。比如在 Tamil 语言 中表示数字 1729 的 ௧௭௨௯ 就可以被 str 正则表达式匹配出来。

在 OS 相关的函数中,很多都同时接受 str 或者 bytes 作为参数。因为 Linux 的文件系统并没有用 Unicode 存储信息,所以有一些文件名很可能是无法被 decode 成 unicode 的。Python 3 提供了 fsdecodefsencode 函数,用来做文件名相关的字符串在 str 与 bytes 之间的相互转换。转换时用的编码是 sys.getfilesystemencoding(),并且用了 surrogateescape 来做错误处理(Linux 上)。

surrogateescape 挺有意思的。它使用了 Unicode 中的 Low Surrogate Area(U+DC00 到 U+DCFF)。这个区域仅限程序内部使用,即是说不作为数据在外部交换使用。fsdecode 时将不能 decode 成 unicode 的字节以 U+DCXX 的形式存起来,fsencode 时再把它解开:

>>> os.listdir('.')  
['abc.txt', 'digits-of-π.txt']
>>> os.listdir(b'.')  
[b'abc.txt', b'digits-of-\xcf\x80.txt']
>>> pi_name_bytes = os.listdir(b'.')[1]  
>>> pi_name_str = pi_name_bytes.decode('ascii', 'surrogateescape')  
>>> pi_name_str  
'digits-of-\udccf\udc80.txt'
>>> pi_name_str.encode('ascii', 'surrogateescape')  
b'digits-of-\xcf\x80.txt'

留意上面的 \udccf\udc80

延伸阅读里面有一堆相关的好材料。