深入理解Python字典

字典是 Python 中的一种重要的数据类型,它不仅可以用于存储任何映射类型的数据,也是构成 Python 语言的基石,从可变的关键字参数对象的属性,各个方面都有字典的存在。Python 的字典在保持很高效率的同时,也兼具易用性。

字典类型

Python中的映射

在 Python 中,映射(mapping)是容器类型的一种,映射类型可以存储(key)(value)对形式的数据,并且可以根据键快速查找对应的值。collections.abc 模块提供了抽象基类 MappingMutableMapping ,分别表示映射和可变映射,下图展示了映射类型支持的方法:

字典(dictionary)是 Python 最主要、也是最基本的映射类型。Python 内置类型 dict 提供了对字典的支持。Python 的字典类似其它编程语言的 MapHashMap ,底层原理也是散列表。因此,字典的键必须是一个可散列对象,这和集合是一样的。

注意:由于字典被设计为处理更通用的工作,因此 Python 中的某些映射对象的类型可能不是字典,而是另一种针对性优化后的映射类型:

>>> class Something: ... >>> isinstance(Something.__dict__, dict) False >>> from collections.abc import Mapping >>> isinstance(Something.__dict__, Mapping) True

创建字典

在 Python 中,字典字面量是使用花括号 { } 包含的数据,其中每对键和值对用冒号 : 分割,不同键值对之间用逗号 , 分割,例如:

city = { 'name': 'Beijing', 'population': 2184.3, 'area': 16410, 'coordinate': (116.2000, 39.5060), 'code': { 'administration': 110000, 'postal': 100000 } }

在实际存储时,键保证唯一,就像集合一样。但在构造字典时,键可以重复,此时最后的一个键值对会替换前面的键值对。值可以取任何数据类型,但键必须是不可变的数据(更严谨的说是可散列类型),如数值和字符串。

dict() 的构造方法提供了更丰富的构造字典的方式。根据映射的概念,任何由有序的两个元素构成的序列都可以用于构造字典,例如嵌套列表或元组列表:

>>> dict([('one', 1), ... ('two', 2), ... ('three', 3)]) {'one': 1, 'two': 2, 'three': 3}

或者每次产出两个值的迭代器也可以:

>>> dict(enumerate(['zero', 'one', 'two'])) {0: 'zero', 1: 'one', 2: 'two'}

还可以通过构造函数中的关键字参数:

>>> dict(first=1, second=2, last=10) {'first': 1, 'second': 2, 'last': 10}

当然,还可以通过字典字面量构造另一个字典:

>>> dict({'a': 1, 'b': 2, 'c': 3}) {'a': 1, 'b': 2, 'c': 3}

如果只知道字典中包含了哪些键,那么 dict 也提供了一个工厂方法 .fromkeys() 从可迭代对象中构造字典,并将所有的值预先设置为 None

>>> dict.fromkeys(['c', 'd', 'e']) {'c': None, 'd': None, 'e': None}

还可以通过 .fromkeys() 的第二个参数 initial 设置其它的默认值。

字典的操作

Python 为字典提供了丰富的操作和易用的接口。

元素操作

像序列的索引一样,字典也可以使用方括号根据键获取对应的值:

>>> city['name'] 'Beijing'

但是,方括号形式获取值时,如果对应的键不存在,会引发 KeyError 异常。in 操作符可以作用于字典,判断字典中是否有某个键:

>>> 'density' in city False

一种更为安全的获取字典值的方式是使用 .get() 方法,它会在对应的键不存在时返回 None ,而不是直接发生错误:

>>> print(city.get('density')) None

但是,使用 .get() 方法不能区分是因为没有对应的键得到 None ,还是这个键映射的值就是 None 。如果确实需要对这两种情况做出区分,应该使用 in 操作符。

.get() 方法的第二个参数 default 指定当键不存在时,方法返回的默认值。

可以使用方括号赋值的形式为字典添加上新的键值对:

>>> city['density'] = city['population'] / city['area'] >>> city['density'] 0.13310786106032907

以下是一个经典的用法,使用字典统计一系列词汇的词频,.get() 方法的默认值用于处理当前词频中还没有该词汇的情况:

frequency = {} for word in words: frequency[word] = frequency.get(word, 0) + 1

使用 del dict[key] 语句可以直接删除字典键为 key 的键值对,但是它也有对应的键不存在时会引发 KeyError 异常的情况。字典的 .pop(key, default) 可以通过第二个参数 default 处理键不存在时的默认返回值。(但是不像 .get() 方法,在没有使用 default 参数时它还是会发生 KeyError

.pop() 方法在删除键值对后可以返回对应的值。以下是一种常见的使用场景,在扩展了某个函数的功能后,使用 .pop() 方法从可变的关键字参数中取出这一环节要用到的值,再将剩下的值转交下一环节统一处理,从而避免多余的参数污染,也保证了类型安全:

class ParallelRequest(Request): def __init__(self, **kwargs): self.max_workers = kwargs.pop('max_workers', 4) # ... super().__init__(kwargs)

最后,.popitem() 方法随机返回一个键值对,并从字典内删除它。

注意,新版本的 Python 中,.popitem() 方法返回的是最后添加的键值对。稍后会讨论字典的顺序问题。

迭代

字典实现了 .__len__() 方法,因此可以使用 len() 函数获取字典中实际的键值对个数。

默认情况下,对字典的迭代只会获取字典所有键构成的迭代器,如果确实需要同时使用键和值,需要使用 .items() 方法得到所有的键值对:

for word, times in frequency.items(): print(f'word {word} appeared {times} times')

如果只关心字典的值,可以使用 .values() 方法返回字典所有值。相应地也有 .keys() 方法获取所有键。不过注意:这三个方法返回的结果都不是列表,甚至都不是序列对象,而是一些特殊的字典视图对象,这意味着无法对它们应用列表的方法,甚至都不支持使用索引,也不能直接修改元素。

合并与清空

如果要将一个字典的键值对合并到另一个字典上,可以调用另一个字典的 .update(other) 方法,相同的键由 other 字典决定值。

该方法会改变字典,因此这个方法被命名为“更新”。如果只是想通过合并创建一个新字典,Python3.5 引入了通过两个星号 ** 解包形式的字面量语法,从多个字典中创建新字典:

>>> a = dict(one=1, two=2, three=3) >>> b = dict(three=13, four=4, five=5) >>> {**a, **b} {'one': 1, 'two': 2, 'three': 13, 'four': 4, 'five': 5}

Python3.9 还通过重载按位或运算符 | 实现了更简便的合并方式:

>>> a | b {'one': 1, 'two': 2, 'three': 13, 'four': 4, 'five': 5}

并且通过按位或的赋值形式 |= 可以实现更简洁的更新操作。

在 Python3.4 及之前的版本如果要通过合并创建新字典,可能需要用到字典的 .copy() 方法复制自身,并且做的也是浅复制

最后,.clear() 方法用于清空字典,移除字典中的所有键值对。

不可变字典

在 Python 中,列表有对应不可变的类型元素,集合也有对应不可变的类型 frozenset,但是字典却没有对应不可变的类型。这是由于不可变字典的应用场景实在很少,而且多数可能的应用场景都可以被命名元组frozenset(dict.items()) 取代。

如果仅仅是想要阻止使用者应用修改操作,可以使用 types 模块提供的 MappingProxyType 类,这个类在使用者尝试应用修改相关的操作时会抛出异常:

>>> from types import MappingProxyType >>> immutable_dict = MappingProxyType({'a': 1, 'b': 2, 'c': 3}) >>> immutable_dict['b'] 2 >>> immutable_dict['b'] = 3 Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: 'mappingproxy' object does not support item assignment

但是,MappingProxyType 存在一些问题:

  1. MappingProxyType 是一个代理(proxy)对象,即它只保存了对原始映射的引用,绕过 MappingProxyType 直接修改原始映射依旧是可行的
  2. 因此,MappingProxyType 仍然是不可散列的类型,不能作为集合的元素或字典的键

如果确实需要一个完善的不可变的字典,可以检索 pypi 上的第三方库。

字典推导式

列表推导式和生成器表达式的概念也可以应用到字典上。字典推导式(dict comprehension)可以从任何可迭代对象中提起键值对,从而构造字典。

例如,对于以下数据:

employees = [ ('Alice', 136123456789, 'Engineer'), ('Bob', 155987654321, 'Designer'), ('Charlie', 133555555555, 'Developer'), ('David', 139111223344, 'Manager'), ('Eva', 152888888888, 'Analyst') ]

字典推导式的用法示例为:

>>> {name: f'+86 {phone}' ... for name, phone, position in employees ... if position != 'Manager'} {'Alice': '+86 136123456789', 'Bob': '+86 155987654321', 'Charlie': '+86 133555555555', 'Eva': '+86 152888888888'}

字典的处理

字典的默认值

字典处理起来有一个常见的问题:在通过键获取值时,这个键可能不存在字典中,此时就需要考虑如何处理缺失值。

显然,最差的处理方式就是通过捕获 KeyError 异常,在 except 中处理键不在字典中的情况。一般情况下,.get() 方法可以代替 in 操作符的功能,同时处理键存在与不存在的两种值的情况,就像以上的示例一样。

但是,如果处理的值比较复杂,.get() 方法用起来可能也不太方便。例如,如果要扩充以上统计词频的功能,使其能保存每次出现的位置,那么就无法在一行内处理了:

occurrence = {} for row, line in enumerate(file, 1): for match in word_pattern.finditer(line): word = match.group() col = match.start() + 1 position = (row, col) # add new item this_occurrence = occurrence.get(word, []) this_occurrence.append(position) occurrence[word] = this_occurrence

如果不是执意要用 .get() 方法,使用 in 操作符提前检查对应的键是否存在,可能稍微易读一些:

# add new item if word not in occurrence: occurrence[word] = [] occurrence[word].append(position)

实际上,Python 的字典还提供了一个 .setdefault(key, default) 方法,它的处理方式为:如果字典有键 key ,那么返回它对应的值;如果没有这个键,为字典添加上 key 键和它对应的值 default 后,再返回设置的值 default 。虽然它的名字带上了 set ,但它实际上是一种会在键缺失时做 set 的 get 行为。

.setdefault(key, default) 方法的这种行为就非常适合用于处理键缺失的情况。例如,以上情况使用 .setdefault() 就可以在一行内处理完了:

# add new item occurrence.setdefault(word, []).append(position)

不过,setdefault 这个名字可能会给没有见过该方法的人带来一定困惑。实际应该使用哪种处理方式,可以根据使用场景决定。


这几种处理方式虽然一定程度上解决了缺失值的问题,但还存在一个关键的问题:这些缺失值是字典的使用者提供的,而不是这个字典的创建者提供的:如果这个字典是某个第三方库提供的,可能很难理解它期待使用什么值作为默认值;并且如果这个默认值构造起来比较复杂,在程序中多次处理也比较麻烦。

为了解决这个问题,可以使用 collections 提供的 defaultdictdefaultdictdict 的子类,用于在尝试获取不存在的键的值时,使用预先设置的函数提供默认值。其构造函数为:

defaultdict(default_factory, ...)

可以为可选参数 default_factory 提供一个工厂函数,为字典中不存在的键构造初始值。其它参数同 dict 的构造函数。

例如,以上词频统计的示例要求在词语不存在时使用空列表作为默认值,那么就可以为 default_factory 提供列表的构造函数:

occurrence = defaultdict(list)

这样使用者在任何时候都可以直接访问 occurrence 的任何键,缺失的情况将由字典本身处理:

# add new item occurrence[word].append(position)

这样的代码写起来就非常简洁且清晰了。


defaultdict 可以自动处理键不存在的情况,是因为它实现了 __missing__ 特殊方法,它是针对映射类型的特殊方法,因为映射类型经常需要处理键不存在(即 .__getitem__() 失败)的情况,此时 .__missing__() 就用于处理这种情况。dict 并没有实现这种方法,所以它只会抛出异常。

defaultdict 是一种实现了 .__missing__() 的字典,它会将工厂函数存储到 .default_factory 属性,在 .__missing__() 中通过这个属性给未找到的元素设置值。注意:由于 .__missing__() 不会被 .__getitem__() 以外的其它方法调用,因此 defaultdict.get() 方法会像正常的 dict 那样返回 None ,而不是使用 default_factory 提供的默认值。

defaultdictdefault_factory 要求是一个无参数函数,这就意味着它不能根据不同键的情况设置不同的默认值。如果确实有这个要求,可以通过继承 dict 并提供自定义的 .__missing__(self, key) 方法处理键 key 不同的默认值处理方式。

例如,以下实现了一个 FilePool 类,它会在键不存在时创建新的文件名与文件 IO 类,自动管理文件对象的打开与关闭:

class FilePool(dict): def __missing__(self, key): file = open(key, 'r+') self[key] = file return file def __delitem__(self, key): self[key].close() super().__delitem__(key) def __del__(self): for file in self.values(): file.close()

这个类在键不存在时会自动处理文件的打开工作,并在变量回收前处理文件的关闭工作:

>>> log_files = FilePool() >>> log_files['demo1.log'].write('hello') >>> log_files['demo2.log'].write('world') >>> log_files['demo1.log'].seek(0) 0 >>> log_files['demo1.log'].read() 'hello'

字典的顺序

字典并不是序列,字典的键值对之间并不总是按添加的顺序排列,因此不要依赖字典的遍历顺序来做任何假设或操作:

>>> ranks = {} >>> ranks["one"] = 1 >>> ranks["two"] = 2 >>> ranks["three"] = 3 >>> ranks {'three': 3, 'one': 1, 'two': 2}

例如,假设要从某个排序后的序列构建字典,再将这个字典转换为序列就可能破坏这个顺序。如果有顺序的要求,collections 提供了 OrderedDict 类,它是一种有序字典,可以严格地记录字典中各键值对的顺序。

OrderedDict 的构造函数与 dict 构造函数的用法一致,其余方法用法也完全相同,只有 .popitem() 结果有一些不同:dict.popitem() 返回的键值对可能是随机的,但 OrderedDict.popitem() 会移除并返回字典里最后插入的键值对;并且该方法还有一个额外的 last=True 参数,如果传入 False 会使其移除并返回最先插入的键值对。

OrderedDict 还提供了 .move_to_end(key, last=True) 方法,可以移动键 key 到最后。如果 last 值为 False ,则移动到最前面:

>>> from collections import OrderedDict >>> d = OrderedDict(a=1, b=2, c=3) >>> d OrderedDict([('a', 1), ('b', 2), ('c', 3)]) >>> d.move_to_end('a') >>> d OrderedDict([('b', 2), ('c', 3), ('a', 1)])

现在的 Python 已经很少见到 OrderedDict 的使用了,这是因为在 Python3.6 后,内置的字典已经可以保留键值对在添加时的顺序了;Python3.7 正式将字典的有序性作为语言的规范。但是直到 Python3.8 字典才实现了 .__reversed__() 方法,使其可以被逆序迭代。考虑到兼容性,最好还是采用 OrderedDict 处理有序的映射。

虽然现在 dictOrderedDict 用起来几乎完全一致,但是由于底层实现不太一样,两者用起来仍然存在一定区别:

  • 标准的 dict 是专门用于处理映射的类型,它在处理映射信息时速度很快,且占用空间也小
  • OrderedDict 更擅长处理键值对的顺序,可以比 dict 更好地处理频繁的重新排序操作,这使其更适用于跟踪最近的访问。但 OrderedDict 的空间效率和更新操作的性能则稍微差一些

OrderedDict 的一个典型应用就是实现 LRU(Least Recently Used) 缓存,这种缓存会在容量达到限制之后,首先移除最久没有用到的缓存项。使用有序的字典可以很容易地实现这种缓存。

字典的排序

对于有序的字典,将其排序也是一种常见的需求。但是注意:内置的 sorted() 函数在处理字典时,只会迭代字典的所有键,所以为了保留映射关系,需要使用 items() 获取键值对的可迭代对象:

>>> student_grades = dict(Bob=92, David=95, Charlie=78, Alice=85, Eva=88) >>> sorted(student_grades.items()) [('Alice', 85), ('Bob', 92), ('Charlie', 78), ('David', 95), ('Eva', 88)]

因为 sorted() 函数会以元组的第一个参数作为排序依据,如果需要对字典值做排序,需要使用 key 参数改变排序的依据:

def sort_dict_value(d, reverse=False): return dict(sorted(d.items(), key=lambda item: item[1], reverse=reverse))

这样这个函数就可以处理字典值的排序要求了,使用效果为:

>>> sort_dict_value(student_grades) {'Charlie': 78, 'Alice': 85, 'Eva': 88, 'Bob': 92, 'David': 95}

可以使用该函数对上文处理的词频字典排序。如果需要处理这种计数问题,可以使用 collections 提供的 Counter 类,它是 dict 的子类,专门用于计数可散列对象。它会将被计数的元素存储为字典的键,计数结果存储为值。计数结果是一个整型数值,包括 0 和负数。

Counter 的构造函数用法和字典很像,但它可以接收一个序列或序列型可迭代对象,表示对该序列对象的元素计数:

>>> from collections import Counter >>> Counter('eqwrewqrtewqrqwet') Counter({'e': 4, 'q': 4, 'w': 4, 'r': 3, 't': 2})

如果直接传入一个映射,表示直接使用这个映射作为计数结果。但 Counter 并没有提供 .fromkeys() 方法,不能直接用序列生成一系列键。

通常字典方法都可用于 Counter 对象,但在查询时如果键不存在,Counter 不会报错而是返回 0 :

>>> frequency = Counter(words) >>> frequency['notacharacter'] 0

Counter.update() 也可以更新自身,不过参数可以是任意它的构造方法能接受的值,从而方便继续计数:

>>> chars = Counter('reqwewqtewqrew') >>> chars Counter({'e': 4, 'w': 4, 'q': 3, 'r': 2, 't': 1}) >>> chars.update('ewrqewqetrqtwe') >>> chars Counter({'e': 8, 'w': 7, 'q': 6, 'r': 4, 't': 3})

Counter 还提供了一些额外的方法处理其它计数问题。例如,.subtract() 方法从参数中减去计数结果,相当于 .update() 的反向操作。但是在相减后,计数结果可能出现零或负数:

>>> chars.subtract('rytretwqt') >>> chars Counter({'e': 7, 'w': 6, 'q': 5, 'r': 2, 't': 0, 'y': -1})

除此之外,Counter 还通过重载运算符实现了多个计数结果的组合:

>>> c1 = Counter(a=2, b=6, c=4) >>> c2 = Counter(a=6, b=5, c=3) >>> c1 + c2 Counter({'b': 11, 'a': 8, 'c': 7}) >>> c2 - c1 Counter({'a': 4}) >>> c1 & c2 Counter({'b': 5, 'c': 3, 'a': 2}) >>> c1 | c2 Counter({'a': 6, 'b': 6, 'c': 4})

使用运算符的合并会自动过滤为零或负值的计数结果。

如果要对计数结果做进一步统计,Counter 也提供了相应的方法。例如,.most_common(n=None) 会返回计数值最大的 n 个元素。如果忽略 n ,则依照计数值从大到小返回所有元素:

>>> frequency.most_common(5) [('the', 1644), ('and', 872), ('to', 729), ('a', 632), ('it', 595)]

.elements() 用于从计数结果恢复原有序列:

>>> ''.join(c1.elements()) 'aabbbbbbcccc'

Python3.10 还为 Counter 引入了一个新的方法 .total() ,用于计算计数总和:

>>> frequency.total() 27343

使用ChainMap连接字典

Python 3.3 在标准库 collections 引入了一个新的容器 ChainMap ,它提供了一种将多个映射链式相接实现合并效果的方式。

ChainMap 的构造函数可以接受任意个映射对象,它将这些映射对象“串联”成一个统一的、可读写的视图。相比 dict.updateChainMap 的连接不涉及拷贝,因此效率很高。

ChainMap 会将这些映射对象收集在一个列表中,可以通过 .maps 属性获取这个映射列表。ChainMap 支持所有字典的操作方法,具体实现的细节为:

  • 查找时依次搜索各映射,找到一个存在的键便返回;
  • 写入、更新、删除操作只作用于第一个映射。

由于 ChainMap 只保存映射的引用,因此如果通过别的途径修改映射对象,ChainMap 也能实时更新。

ChainMap 可以用于具有嵌套关系的查找上下文,这样各个上下文的变化都能实时更新,也不会互相影响。Python 官方文档列举了两个 ChainMap 的典型应用场景:

  1. ChainMap 可以很方便地实现嵌套作用域(如 locals > globals > builtins ),例如 Python 内部作用域的查找可以表示为:
import builtins nested_scope = ChainMap(locals(), globals(), vars(builtins))
  1. ChainMap 还用于配置覆盖(如命令行参数 > 环境变量 > 默认值),例如:
import os, argparse defaults = {'host': '127.0.0.1', 'port': ...} parser = argparse.ArgumentParser() parser.add_argument('-h', '--host') parser.add_argument('-p', '--port') namespace = parser.parse_args() command_line_args = {k: v for k, v in vars(namespace).items() if v is not None} env = ChainMap(command_line_args, os.environ, defaults)

除了字典通用的操作方法外,ChainMap 还引入了以下操作:

  • 通过 .parents 属性可以跳过最前的上下文,d.parents 等价于 ChainMap(*d.maps[1:])
  • 通过 .new_child() 方法在最前快速创建“子上下文”,d.new_child() 等价于 ChainMap({}, *d.maps)

因为创建 ChainMap 的开销很低,这两个属性或方法都会得到新的 ChainMap 对象。

参考资料/延伸阅读

Mapping Types: dict — Built-in Types — Python 3 documentation

collections — Python Standard Library — Python 3 documentation

京ICP备2021034974号
contact me by hello@frozencandles.fun