本章将讨论以下话题:
(有一些东西觉得用不到,就没有记,到时候用到可以对照目录看书)
字符串是个简单的概念,一个字符序列,问题出现在 “字符” 的定义上。在 2015 年 “字符” 的最佳定义是 Unicode 字符,因此,从 Python 3 的 str 对象获得的元素是 Unicode 字符,这相当于从 Python 2 中的 unicode 对象中获取的元素,而不是从 Python 2 中的 str 对象获取原始字节序列。
把码位转成字节序列的过程叫编码,把字节序列转换成码位的过程是解码。下面展示了这一区分:
In [1]:
s = 'safé' # 一共有 4 个 Unicode 对象
len(s)
Out[1]:
In [2]:
b = s.encode('utf8') # 使用 UTF-8 把 str 对象编码成 bytes 对象
b
Out[2]:
In [3]:
len(b) # 在 UTF-8 中, é 编码成字节
Out[3]:
In [4]:
b.decode('utf8') #使用 UTF-8 把 bytes 对象解码成 str 对象
Out[4]:
如果想帮自己记住 .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]:
In [2]:
cafe[0] # 每个元素都是 range(256) 的整数
Out[2]:
In [3]:
cafe[:1] # bytes 对象的切片还是 bytes,即使只有一个元素的切片
Out[3]:
In [4]:
cafe_arr = bytearray(cafe)
cafe_arr #bytearray 没有字面量语法,而是以 bytearray() 和字节序列字面量参数形式显示
Out[4]:
In [5]:
cafe_arr[-1:] # bytearray 对象切片还是 bytearray 对象
Out[5]:
my_bytes[0] 获取的是一个整数,而 my_bytes[:1] 获取的是长度为 1 的 bytes 对象。 s[0] == s[:1] 只对 str 序列类型成立,不过 str 这个行为很罕见。对于其他各个序列类型来说,s[i] 返回的是一个元素,s[i:i+1] 返回的是一个相同类型的序列,里面是 s[i] 元素
虽然二进制序列其实是整数序列,但它们的字面量表示法表明其中有 ASCII 文本。因此,各个字节的值可能会用下面 3 种不同的方式显示
因此在上面我们看到 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]:
构建 bytes 或 bytearray 实例还可以调用各自的构造方法,传入以下参数
使用缓冲类对象构建二进制序列是一种底层操作,可能涉及类型转换,下面做了演示:
In [16]:
import array
numbers = array.array('h', [-2, -1, 0, 1, 2]) # h 表示短整数( 16 位)数组
octets = bytes(numbers)
octets # 这是表示 5 个短整数的 10 个字节
Out[16]:
使用缓冲类对象创建 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]:
In [18]:
struct.unpack(fmt, header) # 拆包 memoryview 对象,得到一个元组,包含类型、版本、宽度和高度
Out[18]:
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 是分隔符,默认是空格
虽然有个一般性的 UnicodeError 异常,但是报告错误时几乎都会指明具体的异常:UnicodeEnocdeError(把字符串转换成二进制序列时)或 UnicodeDecodeError(把二进制序列转换成字符串时)。如果源码的编码与预期不符,加载 Python 模块时还可能抛出 SyntaxError。接下来说明如何处理这几种错误。
多数非 UTF 编解码器只能处理 Unicode 字符的一部分子集。把文本转成字节序列时,如果目标编码中没有定义某个字符,就会抛出 UnicodeEncodeError 异常,除非把 errors 参数传给编码方法或函数,对错误进行特殊处理。
In [22]:
city = "SãoPaulo"
city.encode('utf_8') # utf_? 能处理任何字符串
Out[22]:
In [24]:
city.encode('utf_16')
Out[24]:
In [25]:
city.encode('iso8859_1')
Out[25]:
In [26]:
city.encode('cp437') # 无法编码 ã,默认的错误处理方式是 'strict' 抛出 UnicodeEncodeError
In [27]:
city.encode('cp437', errors = 'ignore') # 跳过无法编码的字符,这种做法通常要出大问题
Out[27]:
In [28]:
city.encode('cp437', errors = 'replace') # 把无法编码的字符替换成 '?',数据会损坏了,但是用户知道出了问题
Out[28]:
In [29]:
city.encode('cp437', errors = 'xmlcharrefreplace') # 将无法编码的字符串换成 XML 实体
Out[29]:
编解码的错误处理方式是可扩展的,你可以为 errors 参数注册额外的字串,方法是把一个名称和一个错误处理函数传给 codecs.register_error 函数。
不是每一个字节都包含有效的 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]:
In [31]:
octets.decode('iso8859_7') # iso8859_7 用于编码希腊文,因此无法正确解释 \xe9 字节,而且没抛出错误
Out[31]:
In [32]:
octets.decode('koi8_r') # 编码俄文,同样无法正确解释 \xe9,没抛出错误
Out[32]:
In [33]:
octets.decode('utf_8') # utf_8 检测到 这不是有效的 utf_8 编码,抛出 UnicodeDecodeError 错误
In [34]:
octets.decode('utf_8', errors = 'replace') # 使用 replace 的错误处理方式
Out[34]:
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\xfe' 是 BOM,即 字节序标记(byte-order mark),指明编码时使用 IntelCPU 的小端字节序
小端字节序是低位在前,高位在后。字母 'E' 的编码是 U+0045,十进制数 69,字节的偏移第二位和第三位编码是 69,0(两位代表一个字符)。
In [38]:
list(u16)
Out[38]:
大端高位在前,低位在后。为了避免混淆,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]:
In [41]:
u16 = 'El Niño'.encode('utf_16be')
list(u16)
Out[41]:
根据标准,如果文件使用 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]:
In [43]:
open('cafe.txt').read() # 这个在 Windows 下可能会出现问题,因为 Windows 系统默认编码可能是 cp1252
Out[43]:
In [44]:
s1 = 'café'
s2 = 'cafe\u0301'
s1, s2
Out[44]:
In [45]:
len(s1), len(s2)
Out[45]:
In [46]:
s1 == s2
Out[46]:
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]:
In [48]:
len(normalize('NFC', s1)), len(normalize('NFC', s2))
Out[48]:
In [49]:
len(normalize('NFD', s1)), len(normalize('NFD', s2))
Out[49]:
In [50]:
normalize('NFC', s1) == normalize('NFC', s2)
Out[50]:
In [51]:
normalize('NFD', s1) == normalize('NFD', s2)
Out[51]:
西方键盘通常能输出组合字符,因此用户输入的文本默认是 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]:
注意,在 NFKC 或 NFKD 中可能会损失或曲解信息,例如字符$4^{2}$ 就被转换成 42,损失了原意,但是可以为搜索和索引提供便利的中间表述,例如 用户搜索 '1 / 2 inch' 搜到了 '½ inch' 会非常满意
使用 NFKC 和 NFKD 要小心,而且只能在特殊情况使用,例如搜索和索引,而不能用于持久存储,因为这两种转换会造成数据损失
GNU/Linux 内核不理解 Unicode,因此对于任何合理的编码方案来说,文件名中使用字节序列都是无效的,无法解码成字符串。在不同操作系统中使用各种客户端的文件服务器,在遇到这个问题更容易出错
为了规避这个问题,os 模块的所有函数,文件名或者路径名参数既能使用字符串又能使用字节序列。如果这样的函数使用字符串参数调用,该参数会使用 sys.getfilesystemencoding() 得到编解码器的自动编码,然后操作系统会使用相同的编解码器解码。这几乎就是我们想要的行为,与 Unicode 三明治最佳实践一致
如果必须处理(或者修正)黁写无法使用上述方式自动处理的文件名,可以把啊字节序列参数传给 os 模块中的函数,得到字节序列返回值。这一特性允许我们处理任何文件名或路径名,不管里面有多少鬼符。
In [54]:
import os
os.listdir('./test')
Out[54]:
In [55]:
os.listdir(b'./test') # \xcf\x80 是 π 的 UTF-8 编码
Out[55]:
为了便于手动处理字符串或字节序列形式的文件名或路径名,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]:
In [57]:
os.listdir(b'./test')
Out[57]:
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]:
In [59]:
pi_name_str.encode('ascii', 'surrogateescape') # 再用同样的方式编码回原始值
Out[59]: