这篇文章主要分为两个部分:1. 介绍 Python 的映射类型,包括字典和集合;2. 介绍 Python 的文本和字节序列
字典和集合
dict 类型不但在各种程序里广泛使用,它也是 Python 语言的基石:模块的命名空间、实例的属性和函数的关键字参数等都用到了字典。
泛映射类型
collections.abc
模块中有 Mapping 和 MutableMapping 两个抽象基类,它们的作用是为 dict 和其他类似的类型定义形式接口。但是非抽象类型一般不会直接继承这些抽象基类,它们会直接对 dict 或者 collections.UseDict 进行扩展。抽象基类的主要用途是:
- 提供形式化的文档,定义了构建一个映射类型所需要的最基本的接口
- 与 isinstance 一起被用来判定某个数据是不是广义上的映射类型
标准库里的所有映射类型都是通过 dict 实现,因此它们有共同的限制:即只有可散列的数据类型才能用作这些映射里的键:
- 如果一个对象是可散列的,那么在该对象的生命周期里,它的散列值是不变的,而且这个对象需要实现
_hash_()
方法。另外可散列对象还需要有__eq__()
方法,这样才能和其他键进行比较 - 原子不可变数据类型(str、bytes 和数值类型)都是可散列类型,frozenset 也是可散列的。而对于元祖,只有当一个元祖包含的所有元素都是可散列类型,那么它才是可散列的
- 一般来说,用户自定义类型的对象都是可散列的。散列值是依据 id() 函数的返回值计算出来的。如果一个对象实现了
__eq__()
方法,并且在方法中用到了这个对象的内部状态,那么只有当所有这些内部状态都是不可变的情况下,这个对象才是可散列的
字典提供了多种构造方法:
1 | dict(one = 1, two = 2, three = 3) a = |
字典推导
字典推导可以用来从以任何键值对作为元素的可迭代对象中构建出字典:
1 | 86, "China"), (91, "India"), (81, "Japan")] dial_codes = [( |
常见的映射方法
在映射对象的方法中,setdefault 需要重点推荐一下,一旦它发挥作用时,可以节省不少次的键查询,从而让程序更高效。当字典 d[k] 不能找到正确的键的时候,Python 会抛出异常,有的程序可能会用 d.get(k, default)
给找不到的键一个默认值。但是当要更新某个键对应的值的时候,这种方式就不是最好的方法:
1 | #!/usr/bin/env python3 |
这段代码中为字典赋值的效率并不高,通过 dict.setdefault
可以只用一行就解决:
1 | index.setdefault(word, []).append(location) |
这行代码获取单词所对应的出现记录列表,如果当前单词不存在,则把空列表放进映射,并返回该空列表。这样就不用在进行第二次查找的情况下更新列表了。也就是说,setdefault
的效果等同于如下语句:
1 | if key not in my_dict: |
但是后者至少需要进行两次键查询,如果键不存在的情况下则是三次。而用 setdefault 只需要一次即可完成整个操作。
另外,还介绍一个映射的 update 方法,它用于更新映射的对应条目。它可以接受一个映射或者键值对迭代器。它首先检查参数 m 是否有 keys 方法,如果有,update 方法就把 m 当做映射对象来处理,否则,函数会退一步把 m 当做包含了键值对的迭代器。Python 里大多数映射类型的构造方法都采用了类似的逻辑。
1 | 86: 'CHINA', 91: 'INDIA', 81: 'JAPAN'} country_codes = { |
映射的弹性查询
有时候,即使某个键在映射中不存在,也希望在读取这个键的值时能够得到一个默认值(不是通过 get() 调用)。有两种方法实现该目的:
- 通过 defaultdict 而不是普通的 dict
- 给自己定义一个 dict 的子类,然后在子类中实现
__missing__
方法
defaultdict
用户创建 defaultdict 对象时,需要给它配置一个为找不到键创建默认值的方法。该可调用对象会在 __getitem__
碰到找不到键的时候被调用,让 __getitem__
返回某个默认值。用来生成默认值的可调用对象保存在名为 default_factory
的实例属性里。如果在创建 defaultdict
的时候没有指定 default_factory
,查询不到的键会触发 KeyError。
需要注意,default_factory 只会在 __getitem__
里被调用,在其他方法里不会发挥作用。所以 d[k]
在 k 不存在时会创建一个默认值,而 d.get(k)
则会返回 None。
1 | import collections |
defaultdict 依靠特殊方法 __missing__
在遇到找不到的键时调用 default_factory
。所有映射类型都可以选择去支持 __missing__
。
特殊方法 __missing__
如果一个类继承了 dict
,然后继承类提供了 __missing__
方法,那么就会在 __getitem__
碰到找不到的键的时候,Python 会自动调用它,而不是抛出一个 KeyError 异常。__missing__
方法会回被 __getitem__
调用,而对 get
或者 __contains__
无影响。
如下实现了一个 StrKeyDict 类,它在有非字符串的键被查找时,把它转化为字符串:
1 | #!/usr/bin/env python3 |
字典的变种
这里总结了标准库里的 collections 模块中,除了 defaultdict 之外的不同映射类型:
- collections.OrderedDict:在添加键的时候保持顺序,因此键的迭代次序总是一致的,
- collections.ChainMap:可以容纳不同的映射对象,在进行键查找时,这些对象会被当做一个整体被逐个查找,直到键被找到。该功能在给有嵌套作用域的语言作解释器时很有用,可以用一个映射对象来代表一个作用域的上下文
- collections.Counter:会给键准备一个计数器,每次更新一个键的时候都会增加这个计数器。所以该类型可以用来给可散列对象,或者可以当成多重集合来使用,多重集合是指集合里的元素可以出现不止一次
1 | ct = collections.C |
- collections.UserDict:把标准的 dict 用 Python 又实现了一遍。UserDict 是让用户继承写子类的
子类化 UserDict
当需要创建自定义映射类型时,以 UserDict 为基类,要比普通的 dict 为基类更为方便。因为后者有时会在某些方法上实现上走一些捷径,导致我们需要在子类中重写这些方法。
UserDict 并不是 dict 的子类,但是它有一个叫做 data 的属性,它是 dict 的实例,这个属性实际上是 UserDict 最终存储数据的地方。如下使用 UserDict
重新实现了 StrKeyDict:
1 | import collections |
因为 UserDict 继承的是 MutableMapping,所以 StrKeyDict 里剩下的那些映射类型的方法都是从 UserDict、MutableMapping 和 Mapping 这些超类继承而来。所以我们直接继承了 Mapping.get
方法,它的实现方式能够满足我们对 StrKeyDict 的要求。
不可变映射类型
标准库里所有的映射类型都是可变的,从 Python3.3 开始,types 模块中引入了一个封装类名为 MappingProxyType
,如果给这个类一个映射,它会返回一个只读的映射视图。虽然是个只读视图,但是它是动态的。这意味着如果对原映射做出了改动,通过这个视图可以观察到,但是无法通过这个视图对原映射做出修改:
1 | from types import MappingProxyType |
集合论
集合的本质是许多唯一对象的聚集,因此集合可以用于去重:
1 | 1, 2, 3, 4, 5, 1] l = [ |
集合中的元素必须是可散列的,set 类型本身是不可散列的,但是 frozenset 可以,因此可以创建一个包含不同 frozet 的 set。集合还实现了很多基础的中缀运算符,用于实现集合的合集、交集、差集运算:
1 | 1, 2, 3} s1 = { |
除了速度极快的查找功能,内置的 set 和 frozenset 提供了丰富的功能和操作
集合字面量
集合的字面量使用如下形式表示:{1}
, {'1', '2'}
,注意空集必须写成 set() 的形式,如果写成 {}
,这代表一个空字典:
1 | type({}) |
由于 Python 里没有针对 frozenset 的特殊字面量语法,只能采用构造方法。frozenset 的标准字符串表示就像构造方法调用一样:
1 | frozenset(range(10)) fs = |
集合推导
Python 也支持集合推导:
1 | for i in range(10) if i & 1 } s = {i |
集合的操作
集合所支持的操作不少是运算符重载的特殊方法,其中有些运算符和方法会对集合做就地修改,这类操作在数学中是没有意义的,另外 frozenset 也不会实现这些操作。中缀运算符要求两侧的被操作对象都是集合类型,但是它们的对应方法则只要求被传入的参数是可迭代对象:
1 | 1, 2, 3} s1 = { |
dict 和 set 的背后
Python 中字典和集合的速度是非常快的。这是因为 Python 中的字典背后使用了散列表。散列表其实是一个稀疏的数组(总是有空白的元素称为稀疏数组),散列表的单元通常称为 bucket,在 dict 的散列表当中,每个键值对都占用一个表元,每个表元包含两个部分,一个是对键的引用,一个是对值的引用,由于表元的大小一致,因此可以通过偏移量来读取某个表元。
Python 会设法保证有大约 1/3 的表元是空的,因此每次快达到该阈值时,原有的散列表会被复制到一个更大的空间里。如果要把一个对象放入到散列表当中,首先需要计算这个元素键的散列值,Python 利用 hash() 方法实现它:
- 内置的 hash() 方法可以用于所有内置类型对象上,对自定义对象调用 hash(),实际上运行的是自定义的
__hash()__
。如果两个对象在比较时是相等的,那它的散列值必须是相等,否则散列表就不能正常运行了 - 为了获取 dict[search_key] 所对应的值,会通过 hash(search_key) 计算出 search_key 的散列值后,会把这个值的最低几位数字作为偏移量(取决于当前散列表的大小),在散列表中查找表元,如果找到的表元是空的,则抛出 KeyError,如果不是空的,则比较 search_key 与 found_key(保存在表元中)是否相等,如果相等,则返回 found_value
- 如果不匹配则说明发生了散列冲突,之所以会发生散列冲突,是因为散列表是把随机的元素映射到只有几位的数字上,这样就可能造成散列值本身不同,但是最后的散列索引相同。为了解决散列冲突,算法会在散列值中再取几位来,然后用特殊方法处理后,去新的散列索引上寻找表元。继续重复上述判断
添加新元素和更新现有的键值的操作几乎和上面一样。另外在插入新值时,Python 可能会按照散列表的拥挤程度来决定是否要重新分配内存以进行扩容。如果增加了散列表大小,散列值和散列索引所占用的位数都会增加。
以上 Pthon 散列表的实现给 dict 带来如下的影响:
键必须是可散列的:
- 支持 hash() 函数,并且通过 hash() 方法所得到的散列值是不变的
- 支持 eq() 方法来检测相等性
- 若 a == b,则 hash(a) == hash(b) 也为真
- 所有用户自定义类型的对象默认都是可散列的,因为它们的散列值由 id() 来获取,而且它们都是不相等的。
- 如果一个类实现了 eq 方法,并且希望它是可散列的,则必须要要用一个合适的 hash() 方法,以保证在 a==b 为真时,hash(a) == hash(b) 也为真。另外一个类的 hash() 方法依赖于可变状态时,那么它的实例是不可散列的。
字典在内存上的开销巨大:如果需要存放数量巨大的记录,那么存放在元祖或具名元祖中是比较好的选择。元祖之所以更能节省空间,原因有二:1)避免了散列表所耗费的空间,2)无需把记录中字段的名称在每个元素里都存一遍
键的次序取决于添加顺序:由 dict({key1:value1, key2:value2}) 和 dict({key2:value2, key1:value1}) 得到的两个字段,在进行比较时它们是相等的,但是如果在 key1 和 key2 被添加到字典的过程中发生冲突的话,这两个键出现在字典里的顺序是不一样的
往字典里添加新建可能改变已有键的顺序:往字典中添加新键时,Python 可能会为字典进行扩容。扩容过程会把已有的元素添加到新表中,这个过程可能会发生散列冲突,而导致新散列表中键的次序变化。因此如果在迭代一个字典的所有键的过程中同时对字典进行修改,那么可能会跳过一些键。因此不能对字典同时进行迭代和修改。
在 Python3 中
keys()
、items()
和.values
方法返回的都是视图,视图具有动态特性,可以实时反馈字典的变化。
set 和 frozenset 的实现也依赖于散列表,但是它们的散列表里存放的只有元素的引用。上面对字典与散列表关系的总结,对集合来说几乎适用的:
- 集合里的元素必须是可散列的
- 集合很消耗内存
- 可以很高效地判断元素是否存在于某个集合当中
- 元素的次序取决与被添加到集合里的次序
- 往集合中添加元素,可能改变集合中已有元素的次序
文本和字节序列
Python3 明确区分了人类可读的文本字符串和原始的字节序列。隐式地把字节序列转换成 Unicode 文本已经成为过去。
字符问题
一个字符串是一个字符序列,从 Python3 开始,str 对象中获取的元素是 Unicode 字符。Unicode 标准把字符的标识和具体的字节表述进行了区分:
- 字符的标识:即码位,在 Unicode 标准中以 4-6 个十六进制数字表示,且需要加前缀
U+
。 - 字符的具体表示:取决于所用的编码。编码是在码位和字节序列之间转换时使用的算法。
把码位转换成字节序列的过程是编码,把字节序列转换成码位的过程解码:
1 | "中国" s = |
字节概要
Python 内置了两种基本的二进制序列类型:python3 引入了不可变 bytes 类型和 Python2.6 添加的可变 bytearray 类型。bytes 或 bytearray 对象的各个元素是介于 0-255 之间的整数,而不像 str 对象那样是单个字符。
二进制序列的切片始终是统一类型的二进制序列,即使长度为 1 的切片。bytes 字面量以 b 开头,bytearray 对象没有字面量句法,而是以 bytearray() 和字节序列字面量参数的形式显示。
1 | bytes('中国', encoding='utf_8') c = |
这里 c[0] 返回一个整数,而 c[:1] 返回一个序列。s[0] == [:1] 只对 str 这个序列类型成立,对于其他序列类型都是不成立的。
1 | "abcd" s = |
虽然二进制序列是整数序列,但是它们的字面量表示法表明其中有 ASCII 文本,因此各个字节的值可能会有三种表示方法:
- 可打印的 ASCII 范围内字节,使用 ASCII 字符本身
- 不可打印的 ASCII 范围内的字节,使用转义序列
- 其他字节的值,使用十六进制转义序列
除了格式化方法和几个处理 Unicode 数据的方法,str 类型的其他方法都支持 bytes 和 bytearray 类型,这意味着我们可以使用熟悉的字符串方法处理二进制序列。二进制序列有个类方法是 str 没有的:fromhex,它的作用是解析十六进制数字对,构建二进制序列。
可以通过如下方法构建 bytes 或 bytearray 对象:
- 一个 str 对象和一个 encoding 关键字参数
- 一个可迭代对象,提供 0-255 之间的数值
- 一个实现了缓冲协议的对象(如 bytes、bytearray、memoryview、array.array 等),此时把源对象中的字节序列复制到新的二进制序列中
1 | import array |
如果想从二进制序列中提取结构化信息,struct 模块是重要的工具。struct 模块提供了一些函数,把打包的字节序列转换成不同类型的字段组成的元祖,还有一些函数用于执行反向转换,把元祖转换成打包的字节序列。struct 模块能够处理 bytes、bytearray、memoryview。memoryview 类不是用于创建或者存储字节序列的,而是共享内存,让你访问其他二进制序列、打包的数组和缓冲中的数据切片,而无需复制字节序列。
基本的编解码器
Python 自带了超过 100 种编解码器,用于在文本和字节之间互相转换,每个编解码器都有一个名称,例如 utf8 等:
1 | '中国' s = |
了解编解码问题
虽然有一个一般性的 UnicodeError,但是报告错误时几乎都会指明具体的异常:UnicodeEncodeError 或 UnicodeDecodeError。如果源码的编码与预期不符,加载 Python 模块时还可能抛出 SyntaxError。
- 多数非 UTF 编解码器只能处理 Unicode 字符中的一小部分子集,把文本转换成字节序列时,如果目标编码中没有定义某个字符,那么就会排除 UnicodeEncodeError 异常。可以通过 errors 参数来对错误进行特殊处理
- 把二进制序列转换成文本时,如果遇到无法转换的字节序列,就会抛出 UnicodeDecodeError
- Python3 默认使用 UTF-8 编码源码,如果加载的 .py 模包含 UTF-8 之外的数据,而且没有生命编码,会抛出该异常。为了修正该问题,可以在文件顶部添加 encoding 注释,例如
# encoding: gb2312
。Python3 允许在源码中使用非 ASICC 标识符
编解码器的错误处理方式是可扩展的,可以为 error 参数注册额外的字符串,方法是把一个名称和一个错误处理函数传给 codesc.register_error 函数。
我们不能仅仅根据字节序列,就找出其中的编码。但是就像人类语言也有规则和限制和一样,也可以从字节流中试探和分析找出编码。统一字符编码侦测包 Chardet 能识别所支持的 30 种编码。Chardet 是一个 python 库,可以在程序中使用,它也提供了命令行工具 chardetect。
二进制序列编码文本通常不会明确指自己的编码,但是 UTF 格式可以在文本内容的开头添加一个字节序标记:
1 | '中国' s = |
开头的 b\xff\xfe
即 BOM(byte-order mark),指明编码时的字节序。在小端字节序设备中,各个码位的最低有效字节在前面。在大字节序 CPU 中,编码顺序是相反的。与字节序有关的问题只对一个字占多个字节的编码有影响,UTF8 的一个优势在于:不管设备使用哪种字节序,生成的字节序列始终一致,因此不需要 BOM。
处理文本文件
处理文件的最佳实践是:要尽早把输入(例如读取文件)的字节序列解码成字符串,之后在业务逻辑中处理字符串对象,对输出来说,要尽量晚地把字符串编码成字节序列。这种处理方法也被称为 Unicode 三明治
。
Python3 中可以很容易实现该处理方法,内置的 open 函数会在读取文件时做必要的解码,以文本模式写入文件时还会做必要的编码。需要在多台设备中或多种场合下运行的代码,一定不能依赖默认编码,打开文件时始终应该明确传入 encoding= 参数,因为不同的设备使用的默认编码可能不同。
有几个设置对 Python I/O 的编码默认值有影响:
- 如果打开文件时没有指定 encoding 参数,默认值由 locale.getpreferredencoding() 提供
- 如果设定了 PYTHONIOENCODING 环境变量,sys.stdout/stdin/stderr 的编码使用设定的值,否则继承各自所在的控制台。如果输入/输出重定向到文件中,则由 locale.getpreferredencoding() 提供
- Python 在二进制数据和字符串之间转换时,内部使用 sys.getdefaultencoding() 获得编码。该函数在 Python3 中是供核心开发者使用
- sys.getfilesystemencoding() 用于编解码文件名
关于编码默认值的最佳建议是:别依赖默认值。如果遵从 Unicode 三明治
的建议,并且始终在程序中显式地指定默认编码,那么将避免很多问题。
为了正确比较而规范化 Unicode 字符串
因为 Unicode 有组合字符,所以字符串比较比较复杂。有时候对应用程序而言,应该视为相等的字符,但是 Python 看到的是不同的码位序列,因此不能判定二者相等。该问题的解决方案是使用 unicodedata.normalize
函数提供的 Unicode 规划化。该函数的第一个参数是以下 4 个字符串中的一个:
- NFC:使用最少的码位构成的等价字符串。NFC 也是 W3C 推荐的规范化形式
- NFD:把组合字符分解成基字符和单独的组合字符
- NFKC 和 NFKD:这两种是比较严格的规范化形式,对兼容字符有影响。NFKC 或 NFKD 可能会损失或曲解信息,但是可以为搜索和索引提供便利的中间表述
大小写折叠
大小写折叠其实就是把所有文本都变成小写,再做其他转换。该功能由 str.casefold() 方法支持。自 Python3.4 起,str.casefold() 和 str.lower() 得到不同结果的有 116 个码位。
规范化文本匹配使用函数
如上所述,NFC 和 NFD 可以放心使用,而且能合理比较 unicode 字符串。对于大多数应用来说,NFC 是最好的规范化形式。不区分大小写的比较应该使用 str.casefold()
:
如果要处理多语言文本,可以自己封装两个函数:nfc_equal 可以实现 Unicode 字符串的比较,fold_equal 可以实现忽略大小写的比较:
1 | from unicodedata import normalize |
Unicode 文本排序
Python 比较任何类型的序列时,会一一比较序列里的各个元素。对字符串来说,比较的是码位。可是在比较非 ASCII 字符时,得到的结果不尽如人意。在 Python 中,非 ASCII 文本的标准排序方式是使用 locale.strxfrm 函数,该函数会把字符串转换成适合所在区域进行比较的形式。使用 locale.strxfrm 函数之前,必须先为应用设定合适的区域设置,使用 setlocale 来设置区域:
- 区域设置是全局的,应用或框架应该在进程启动时设定区域设置,而且此后不要修改
- 操作系统必须支持区域设置
- 必须知道如何拼写区域名称
- 操作系统的制作者必须正确实现了所设置的区域
标准库提供的国际化排序方案是可用的,但是似乎只支持 GNU/Linux,而且还要依赖于区域设置,这会为部署带来问题。还有一个较为简单的方案:PyPI 中的 PyUCA 库。它是 Unicode 排序算法(Unicode Collation Algorithm,UCA)的纯 Python 实现。
PyUCA 没有考虑区域设置,如果想要定制排序方式,可以把自定义的排序表路径传给 Collator() 构造方法。
Unicode 数据库
Unicode 标准提供了一个完整的数据库,不仅包括码位和字符名称之间的映射,还有各个字符的元数据,以及字符之间的关系。字符串的 isprintable
、isnumeric
等方法就是靠这些信息进行判断:
1 | import unicodedata |
支持字符串和字节序列的双模式 API
标准库的一些函数能接受字符串或字节序列作为参数,然后根据类型展现不同的行为。re 和 os 模块中就有这样的函数。
正则表中的字符串和字节序列
如果使用字节序列构建正则表达式,\d
、\w
等模式只能匹配 ASCII 字符,但是如果是字符串模式,就能匹配 ASCII 之外的 Unicde 数字或字母。下面程序很好地说明了这一点:
1 | import re |
可以使用正则表达式来搜索字符串和字节序列(字节序列只能用字节序列正则表达式)搜索,但是后一种情况,ASCII 范围外的字节不会当成数字和组成单词的字母。
os 函数中的字符串和字节序列
os 模块中的所有函数、文件名或路径名参数既能使用字符串,也能使用字节序列。如果这样的函数使用字符串参数调用,该参数会使用 sys.getfilesystemencoding()
得到的编解码器自动编码,然后操作系统会使用相同的编解码器解码。如果参数是字节序列,那么返回值也是字节序列:
1 | import os |
为了便于手动处理字符串或字节序列形式的文件名或路径名,os 模块提供了特殊的编码和解码函数:
- fsencode(filename):如果 filename 是 str 类型,使用
sys.getfilesystemencoding()
返回的编解码器把 filename 编码成字节序列,否则返回未经修改的 filename 字节序列 - fsdecode(filename):如果 filename 是 bytes 类型,使用
sys.getfilesystemencoding()
返回的编解码器把 filename 解码成字符串,否则返回未经修改的 filename 字符串