字典(dict)

本篇文章大部分内容摘自流畅的Python

1. 映射类型 -- dict

标准库里的所有映射类型都是利用dict来实现的,因此它们有个共同的限制,即只有可散列的数据类型才能用作这些映射里的键(只有这个键有这个要求,值并不需要是可散列的数据类型)。

1.1 什么是可散列

一个对象的可散列,如果在其生命周期内绝不改变,他的散列值是不会变的 (它需要具有 __hash__() 方法),并可以同其他对象进行比较(它需要具有 __eq__() 方法,实现跟其他键做比较),可散列对象必须具有相同的散列值比较结果才会相同。

一般来讲用户自定义的类型的对象都是可散列的,散列值就是它们的id()函数的返回值。

原子不可变数据类型(str、bytes和数值类型)都是可散列类型,fronzenset也是可散列的,因为根据其定义,fronzenset里只能容纳可散列类型。元组的话,只有当一个元组包含的所有元素都是可散列类型的情况下,它才是可散列的。

1.2 创建

以“Built-in Types”上面有个例子来说明创建字典的不同方式:

a = dict(one=1, two=2, three=3)
b = {'one': 1, 'two': 2, 'three': 3}
c = dict(zip(['one', 'two', 'three'], [1, 2, 3]))
d = dict([('two', 2), ('one', 1), ('three', 3)])
e = dict({'three': 3, 'one': 1, 'two': 2})
f = dict({'one': 1, 'three': 3}, two=2)

print(a == b == c == d == e == f)  # output: True

除了以上面的构造方法之外,还可以利用字典推导(dict comprehension)来创建新的dict

1.2.1 字典推导
get_grade = [('zhangsan', 95,), ('lisi', 90), ('wangwu', 85), ('liuliu', 80)]
name_grade = {name: grade for name,grade in get_grade}
print(name_grade) # output: {'zhangsan': 95, 'lisi': 90, 'wangwu': 85, 'liuliu': 80}

print({name:grade for name,grade in name_grade.items() if grade > 90}) # output:{'zhangsan': 95}

2. 常见的映射方法

映射类型的方法其实很丰富。如下表展示了dict、defaultdict和OrderedDict这三种映射类型的方法列表(依然省略了继承自object的常见方法),defaultdict和OrderedDict是dict的变种,位于collections模块内。

2.1 映射方法
dict defaultdict OrderedDict
d.clear() · · · 移除所有元素
d._contains_(k) · · · 检测k是否在d中
d.copy() · · · 浅复制
d._copy_() · 用于支持copy.copy
d.default_factory · 在__missing__函数中被调用的函数,用以给未找到的元素设置值
d._delitem_(k) · · · del d[k],移除键为k的元素
d.fromkeys(it,, [inital]) · · · 将迭代器it里的元素设置为映射里的键,如果有inital参数,就把它作为这些键对应的值(默认为None)
d.get(k, [default]) · · · 返回键k对应的值,如果字典里没有键k,则返回None或者default
d._getitem_(k) · · · 让字典d能用d[k]的形式返回键k对应的值
d.items() · · · 返回d里所有的键值对
d._iter_() · · · 获取键的迭代器
d.keys() · · · 获取所有的键
d._len_() · · · 可以用len(d)的形式得到字典里键值对的数量
d._missing_(k) · 当__getitem__找不到对应的键的时候,这个方法会被调用
d.move_to_end(k, [last]) · 把键为k的元素移动到最靠前或最靠后的位置(last的默认值是True)
d.pop(k, [defalut]) · · · 返回键k所对应的值,然后移除这个键值对。如果没有这个键,返回None或者default
d.popitem() · · · 随机返回一个键值对并从字典里移除它
d._reversed_() · 返回倒序的键的迭代器
d.setdefault(k, [default]) · · · 若字典里没有键k,则把它对应的值设置为default,然后返回这个值;若无,则d[k] = defalut,然后返回default
d._setitem_(k, v) · · · 实现d[k] = v的操作,把k对应的值设为v
d.update(m, [**kargs]) · · · m可以是映射或者键值对迭代器,用来更新d里对应的条目
d.values() · · · 返回字典里的所有值

OrderedDict.popitem() 会移除字典里最先插入的元素(先进先出);同时这个方法还有一个可选的last参数,若为真,则会移除最后插入的元素(后进后出)

  • update() 接受另一个字典对象,或者一个包含键/值对(以长度为二的元组或其他可迭代对象表示)的可迭代对象。如果给出了关键字参数,则会以其所指定的键/值对更新字典:
d = {}
d.update({'one': 1, 'two': 2})
d.update(red=1, blue=2)
print(d) # output:{'one': 1, 'two': 2, 'red': 1, 'blue': 2}
2.2 用setdefault:处理找不到的键

在映射对象的方法里,setdefault可能是比较微妙的一个。我们虽然并不会每次都用它,但是一旦它发挥作用,就可以节省不少次键查询,从而让程序更高效。

我们都知道用d.get(k, default)来代替d[k],给找不到键一个默认的返回值(这比处理KeyError要方便不少)。但是在更新某个键对应的值的时候,不管使用__getitem__还是get都会不自然,而且效率低。

示例:

d = {}
key = 'name'
new_value = 'zhangsan'
if key not in d:
    d[key] = []
d[key].append(new_value)
print(d) # output{'name': ['zhangsan']}

#------------------------------------------------

d = {}
key = 'name'
new_value = 'zhangsan'
d.setdefault(key, []).append(new_value)
print(d) # output{'name': ['zhangsan']}

上面的两种写法效果一样的,只不过前者至少要进行2次键查询,如果键不存在的话,就是3次,用setdefault只需要1次就可以完成上述操作。

2.3 用defaultdict:处理找不到的键的一个选择

有时候为了方便起见,就算某个键在映射里不存在,我们也希望在通过这个键读取值的时候能得到一个默认值。有两个途径能帮我们达到这个目的,一个是通过defaultdict这个类型而不少普通的dict,另一个是给自己定义一个dict的子类,然后在子类中实现_missing_ 方法。

在collections.defaultdict下可以解决上述说的问题。在用户创建defaultdict对象的时候,就需要给它配置一个为找不到的键创造默认值的方法。

比如,我们新建了这样一个字典:dd=defaultdict(list),如果'key'在dd中不存在的话,表达式dd['key']会按照以下的步骤来行事。

  1. 调用list()来建立一个新列表。
  2. 把这个列表作为值,'key'作为它的键,放到dd中。
  3. 返回这个列表的引用。

而这个用来生成默认值的可调用对象存放在名为default_factory的实例属性里。

import collections

index = collections.defaultdict(list)
key = 'name'
new_value = 'zhangsan'
index[key].append(new_value)
print(index)  # output: defaultdict(<class 'list'>, {'name': ['zhangsan']})
  • 如果在创建default的时候没有指定default_factory,查询不存在的键会触发KeyError。
s = [('yellow', 1), ('blue', 2), ('yellow', 3), ('blue', 4), ('red', 1)]
d = collections.defaultdict(list)
for k,v in s:
    d[k].append(v)
print(sorted(d.items()))
# output:[('blue', [2, 4]), ('red', [1]), ('yellow', [1, 3])]

当每个键第一次遇见时,它还没有在字典里面,所以自动创建该条目,即调用 default_factory 方法,返回一个空的 listlist.append() 操作添加值到这个新的列表里。当再次存取该键时,就正常操作,list.append() 添加另一个值到列表中。

s = 'mississippi'
d = collections.defaultdict(int)

for k in s:
    d[k] += 1
print(d)  # output:defaultdict(<class 'int'>, {'m': 1, 'i': 4, 's': 4, 'p': 2})
print(sorted(d.items()))  # output:[('i', 4), ('m', 1), ('p', 2), ('s', 4)]

当一个字母首次遇到时,它会查询失败,则 default_factory 会调用 int() 来提供一个整数 0 作为默认值。后续的自增操作建立起对每个字母的计数。

函数 int() 总是返回 0,这是常数函数的特殊情况。一个更快和灵活的方法是使用 lambda 函数,可以提供任何常量值(不只是0)

更多defaultdict例子

2.4 特殊方法_missing_

所有的映射类型在处理找不到键的时候,都会牵扯到__missing__方法。虽然基类dict并没有定义这个方法,但是dict是知道有这么个东西存在的。也就是说,如果有一个类继承了dict,然后这个继承类提供了__missing__,那么在__getitem__碰到找不到的键的时候,Python就会自动调用它,而不是抛出一个KeyError异常。

__missing__方法只会被__getitem__调用(比如在表达式d[k]中)

__missing__方法对get或者__contains__(in运算符会用到这个方法)这些方法的使用没有影响。

class missing(dict):

    def __missing__(self, key):
        self[key] = 'default'
        return 'missing'

d = missing([('2', 'two'), ('1', 'one')])
print(d)       # output: {'2': 'two', '1': 'one'}
print(d['2'])  # output: two
print(d['3'])  # output: missing
print(d)       # output: {'2': 'two', '1': 'one', '3': 'default'}

上面代码中,先是继承了dict类,然后定义__missing__方法,当查找取值时,找不到键的时候,以便自动调用它返回一个设定好的默认值。

class strkeydict0(dict):

    def __missing__(self, key):
        if isinstance(key, str):
            raise KeyError(key)
        return self[str(key)]

    def get(self, key, default=None):
        try:
            return self[key]
        except KeyError:
            return default

    def __contains__(self, key):
        #return key in self or str(key) in self # 这样写会陷入递归麻烦
        return key in self.keys() or str(key) in self.keys()


_d = [('2', 'two'), ('1', 'one')]
d = strkeydict0(_d)
print(d['1'])  # output: one
print(d[2])  # output: two
print(d['3'])  # output: ...KeyError: '3'...(error)

print(d.get('2'))  # output: two
print(d.get(1))  # output: one
print(d.get('3', 'three'))  # output: three

print(1 in d)  # output:True
print('2' in d)  # output:True
print('3' in d)  # output:False

在上面示例中,当有非字符串被查找的时候,strkeydict类是如何在该键不存在的情况下,把它转换为字符串的。

这里演示__missing__是如何被dict.__getitem__调用的。

上面的代码中,__contains__方法是在这里是必需的,这是因为k in d 这个操作会调用它。这里有个细节,就是没有用k in my_dict来检查键是否在,因为那会导致__contains__被递归调用为了避免这一情况,这里采取了更显式的方法,直接在这个self.keys()里查询。

3. 字典视图对象

像k in my_dict.keys()这种操作在python3中是很快的,因为dict.keys()的返回值是一个”视图”,还有dict.keys(), 和 dict.items()返回对象也是视图对象。视图就像一个集合,而且跟字典类似,因为其条目不重复且可哈希,所以在视图里查找一个元素的速度很快。但是在python2的dict.keys()返回是列表,它在处理体积大的对象的时候效率不会太高,因为k in my_dict操作需要扫描整个列表。

dishes = {'eggs': 2, 'sausage': 1, 'bacon': 1, 'spam': 500}
keys = dishes.keys()
values = dishes.values()

print(keys)  # output: dict_keys(['eggs', 'sausage', 'bacon', 'spam'])
print(values)  # output: dict_values([2, 1, 1, 500])

# 以键值作为迭代对象
print(list(keys))  # output: ['eggs', 'sausage', 'bacon', 'spam']
print(list(values))  # output: [2, 1, 1, 500]

4. 字典的变种

collections.OrderedDict

它具有专门用于重新排列字典顺序的方法。在添加键的时候会保持顺序,因此键的迭代次序总是一致的。

OrderedDict的popitem方法默认删除并返回的是字典里的最后一个元素,但是如果像my_dict.popitem(last=False)这样调用它,那么它删除并返回第一个被添加进去的元素。

_d = [('2', 'two'), ('1', 'one')]
d = collections.OrderedDict(_d)
print(d.popitem())  # output: ('1', 'one')
d['3'] = 'three'
print(d)  # output: OrderedDict([('2', 'two'), ('3', 'three')])
print(d.popitem(last=False))  # output: ('2', 'two')
collections.ChainMap

一个 ChainMap 类是为了将多个映射快速的链接到一起,这样它们就可以作为一个单元处理。它通常比创建一个新字典和多次调用 update() 要快很多。

这个功能在给有嵌套作用域的语言做解释器的时候很有用,可以用一个映射对象来代表一个作用域的上下文。

>>> baseline = {'music': 'bach', 'art': 'rembrandt'}
>>> adjustments = {'art': 'van gogh', 'opera': 'carmen'}
>>> list(ChainMap(adjustments, baseline))
['music', 'art', 'opera']

注意,一个 ChainMap() 的迭代顺序是通过从后往前扫描所有映射来确定的.

import builtins
pylookup = collections.ChainMap(locals(), globals(), vars(builtins))

上面的示例中,包含了Python变量查询规则

collections.Counter([iterable-or-mapping])

一个 Counter 是一个 dict 的子类,用于计数可哈希对象。它是一个集合,元素像字典键(key)一样存储,它们的计数存储为值。计数可以是任何整数值,包括0和负数。

most_common([n])
返回一个列表,其中包含 n 个最常见的元素及出现次数,按常见程度由高到低排序。

a = collections.Counter('gallahad') # # a new counter from an iterable
print(a)  # output: Counter({'a': 3, 'l': 2, 'g': 1, 'h': 1, 'd': 1})
print(a.most_common(2)) # output: [('a', 3), ('l', 2)]

cnt = collections.Counter()
for word in ['red', 'blue', 'red', 'green', 'blue', 'blue']:
    cnt[word] += 1
print(cnt)  # output: Counter({'blue': 3, 'red': 2, 'green': 1})
# --------------------------等效
cnt1 = collections.Counter(['red', 'blue', 'red', 'green', 'blue', 'blue'])
print(cnt1)  # output: Counter({'blue': 3, 'red': 2, 'green': 1})
子类化UserDict

就创造自定义映射类型来说,以UserDict为基类,总比以普通的dict为基类要来得方便。

而更倾向于从UserDict而不是从dict继承的主要原因是,后者有时会在某些方法的实现上走一些捷径,导致我们不得不在它的子类中重写这些方法,但是UserDict不会带来这些问题。

另外一个值得注意的地方是,UserDict并不是dict的子类,但是UserDict有一个叫做data的属性,是dict的实例(源代码中self.data={}),这个属性实际上是UserDict最终存储数据的地方,这样的好处是,比起前面代码中的strkeydict0类的例子来说,UserDict子类就能在实现__setitem__的时候避免不必要的递归。

class strkeydict(collections.UserDict):

    def __missing__(self, key):
        if isinstance(key, str):
            raise KeyError(key)
        return self[str(key)]

    def __contains__(self, key):
        return str(key) in self.data

    def __setitem__(self, key, value):
        self.data[str(key)] = value


_d = [('2', 'two'), ('1', 'one')]
d = strkeydict(_d)
print(d) 	   # output: {'2': 'two', '1': 'one'}
print(1 in d)  # output: True
print(3 in d)  # output: False
  1. strkeydict是对UserDict的扩展
  2. __missing__方法没有改动
  3. __contains__变得简洁些,这里存储的键都是字符串了,不用担心键可能出现非字符串类型。所以只需在self.data上查询就好了,不想前面strkeydict0类那也去麻烦self.keys()
  4. __setitem__会把所有的键转换成字符串

5. 不可变映射类型

标准库里所有的映射类型是可变的,但有时候你会有这样的需求,比如不能让用户错误地修改某个映射。

从Python3.3开始,types模块中引入了一个封装类名叫MappingProxyType。如果给这个类一个映射,它会返回一个只读的映射视图,但是它是动态的。这意味着如果对原映射做出了改动,我们通过这个视图可以观察到,但是无法通过这个视图对原映射做出修改。

from types import MappingProxyType

d = {1: 'A'}
d_proxy = MappingProxyType(d)
print(d_proxy, type(d_proxy))  # output: {1: 'A'} <class 'mappingproxy'>
print(d_proxy[1])  # output: A

# d_proxy不能做任何修改
d_proxy['2'] = 'two'  # output: TypeError: 'mappingproxy' object does not support item assignment

# d_proxy是动态的,也就是说对d所做的任何改动都会反馈到它上面
d['2'] = 'two'
print(d_proxy, type(d_proxy))  # output: {1: 'A', '2': 'two'} <class 'mappingproxy'>

这样写好后,就可以把一个只读映射视图暴露给API的客户,客户就不能对这个映射进行任何意外的添加、移除或修改操作。

6. 字典中的散列表

散列表其实是一个稀疏数组(总是有空白元素的数组称为稀疏数组),在一般的数据结构教材中,散列表里的单元通常叫作表元(bucket),在 dict 的散列表当中,每个键值对都占用一个表元,每个表元都有两个部分,一个是对键的引用,另一个是对值的引用。因为所有表元的大小一致,所以可以通过偏移量来读取某个表元。

因为 Python 会设法保证大概还有三分之一的表元是空的,所以在快要达 到这个阈值的时候,原有的散列表会被复制到一个更大的空间里面。 如果要把一个对象放入散列表,那么首先要计算这个元素键的散列值。

6.1 散列表算法

为了获取 my_dict[search_key] 背后的值,Python 首先会调用 hash(search_key) 来计算 search_key 的散列值,把这个值最低 的几位数字当作偏移量,在散列表里查找表元(具体取几位,得看 当前散列表的大小)。若找到的表元是空的,则抛出 KeyError 异常。若不是空的,则表元里会有一对 found_key:found_value。 这时候 Python 会检验 search_key == found_key 是否为真,如果它们相等的话,就会返回 found_value。

如果 search_key 和 found_key 不匹配的话,这种情况称为散列冲突。发生这种情况是因为,散列表所做的其实是把随机的元素映 射到只有几位的数字上,而散列表本身的索引又只依赖于这个数字 的一部分。为了解决散列冲突,算法会在散列值中另外再取几位,然后用特殊的方法处理一下,把新得到的数字再当作索引来寻找表 元。 若这次找到的表元是空的,则同样抛出 KeyError;若非空,或者键匹配,则返回这个值;或者又发现了散列冲突,则重复以上的步骤。如图展示了这个算法的示意图。

image

从字典中取值的算法流程图;给定一个键,这个算法要 么返回一个值,要么抛出 KeyError 异常

我们也可以CPython 源码里查看到打乱散列值位的算法,详细查看dictobject.c

如下lookdict_index的函数使用了开放寻址算法实现了从条目表的偏移量搜索哈希表索引

lookdict_index(PyDictKeysObject *k, Py_hash_t hash, Py_ssize_t index)
{
    size_t mask = DK_MASK(k);
    size_t perturb = (size_t)hash;
    size_t i = (size_t)hash & mask;

    for (;;) {
        Py_ssize_t ix = dictkeys_get_index(k, i);
        if (ix == index) {
            return i;
        }
        if (ix == DKIX_EMPTY) {
            return DKIX_EMPTY;
        }
        perturb >>= PERTURB_SHIFT;
        i = mask & (i*5 + perturb + 1);
    }
    Py_UNREACHABLE();
}

上面代码中想了解更多,查看https://zhuanlan.zhihu.com/p/365804855

7.总结

字典算得上是 Python 的基石。除了基本的 dict 之外,标准库还提供现 成且好用的特殊映射类型,比如 defaultdict、OrderedDict、ChainMap 和 Counter。这些映射类型 都属于 collections 模块,这个模块还提供了便于扩展的 UserDict 类。

大多数映射类型都提供了两个很强大的方法:setdefault 和 update。setdefault 方法可以用来更新字典里存放的可变值(比如列 表),从而避免了重复的键搜索。update 方法则让批量更新成为可 能,它可以用来插入新值或者更新已有键值对,它的参数可以是包含 (key, value) 这种键值对的可迭代对象,或者关键字参数。映射类型 的构造方法也会利用 update 方法来让用户可以使用别的映射对象、可 迭代对象或者关键字参数来创建新对象。

在映射类型的 API 中,有个很好用的方法是 missing,当对象找 不到某个键的时候,可以通过这个方法自定义会发生什么。

collections.abc 模块提供了 Mapping 和 MutableMapping 这两个抽 象基类,利用它们,我们可以进行类型查询或者引用。不太为人所知的 MappingProxyType 可以用来创建不可变映射对象,它被封装在 types 模块中。另外还有 Set 和 MutableSet 这两个抽象基类。

dict 背后的散列表效率很高,对它的了解越深入,就越能理解为什么被保存的元素会呈现出不同的顺序,以及已有的元素顺序会发生 变化的原因。同时,速度是以牺牲空间为代价而换来的。

posted @ 2021-07-30 12:30  Rosaany  阅读(308)  评论(0编辑  收藏  举报