楔子
本篇文章来聊一聊 Python 的集合是怎么实现的?前面我们介绍了字典的实现原理,它底层是基于哈希表实现的,而集合也是如此。
事实上,集合就类似于没有 value 的字典。
集合的使用场景
那么集合都有哪些用处呢?
1)去重
chars = ["a", "b", "a", "c", "c"] print( list(set(chars)) ) # ['b', 'a', 'c']
比如你需要监听一个队列,处理接收到的消息,但每一条消息都有一个编号,要保证具有相同编号的消息只能被处理一次,要怎么做呢?
显然集合此时就派上用场了,我们可以创建一个集合,每来一条消息,就检测它的编号是否在集合中。如果存在,则说明消息已经被处理过了,忽略掉;如果不存在,说明消息还没有被处理,那么就将它的编号添加到集合中,然后处理消息。
2)判断某个序列是否包含指定的多个元素
data = ["S", "A", "T", "O", "R", "I"] # 现在要判断 data 是否包含 "T"、"R" 和 "I" # 如果使用列表的话 print( "T" in data and "R" in data and "I" in data ) # True # 显然使用列表比较麻烦,并且效率也不高,于是我们可以使用集合 print( set(data) >= {"T", "R", "I"} ) # True
同理,基于此方式,我们也可以检测一个字典是否包含指定的多个 key。
data = { "name": "satori", "age": 17, "gender": "female" } # 判断字典是否包含 name、age、gender 三个 key print( data.keys() >= {"name", "age", "gender"} ) # True # 字典的 keys 方法会返回一个 dict_keys 对象 # 该对象具备集合的性质,可以直接和集合进行运算
显然对于这种需求,有了集合就方便多了。
集合的 API
然后我们来罗列一下集合支持的 API,在使用集合的时候要做到心中有数。
# 如果是创建一个空集合,那么要使用 set() # 写成 {} 的话,解释器会认为这是一个空字典 s = {1, 2, 3} # 添加元素,时间复杂度是 O(1) s.add(4) print(s) # {1, 2, 3, 4} # 删除指定的元素,如果元素不存在,会抛出 KeyError # 时间复杂度为 O(1) s.remove(2) print(s) # {1, 3, 4} # 删除指定的元素,如果元素不存在则什么也不做 # 时间复杂度为 O(1) s.discard(666) print(s) # {1, 3, 4} # 随机弹出一个元素并返回,如果集合为空,会抛出 KeyError # 时间复杂度为 O(1) print(s.pop()) # 1 print(s) # {3, 4} # 清空一个集合 s.clear() print(s) # set() # 还有一些 API,但我们更推荐使用操作符的方式 # 两个集合取交集 print({1, 2} & {2, 3}) # {2} # 两个集合取并集 print({1, 2} | {2, 3}) # {1, 2, 3} # 两个集合取差集 # s1 - s2,返回在 s1、但不在 s2 当中的元素 print({1, 2, 3} - {2, 3, 4}) # {1} # 两个集合取对称差集 # s1 ^ s2,返回既不在 s1、也不在 s2 当中的元素 print({1, 2, 3} ^ {2, 3, 4}) # {1, 4} # 判断两个集合是否相等,也就是内部的元素是否完全一致 # 顺序无所谓,只比较元素是否全部相同 print({1, 2, 3} == {3, 2, 1}) # True print({1, 2, 3} == {1, 2, 4}) # False # 判断一个集合是否包含另一个集合的所有元素 # 假设有两个集合 s1 和 s2: # 如果 s1 的元素都在 s2 中,那么 s2 >= s1; # 如果 s2 的元素都在 s1 中,那么 s1 >= s2; # 如果 s1 和元素和 s2 全部相同,那么 s1 == s2; print({1, 2, 3} > {1, 2}) # True print({1, 2, 3} >= {1, 2, 3}) # True
以上就是集合支持的一些 API,还是很简单的。
集合的底层结构
集合和字典的内部都使用了哈希表,但字典的哈希表采用两个数组实现,而集合的哈希表采用一个数组实现。因此对于集合来说,这个数组不仅要存储 entry,并且映射出的索引也是该数组的索引。
下面看一下集合的底层结构长什么样子。
// Include/cpython/setobject.h typedef struct { PyObject_HEAD Py_ssize_t fill; Py_ssize_t used; Py_ssize_t mask; setentry *table; Py_hash_t hash; Py_ssize_t finger; setentry smalltable[PySet_MINSIZE]; PyObject *weakreflist; } PySetObject;
解释一下这些字段的含义:
PyObject_HEAD
定长对象的头部信息,但集合显然是一个变长对象,所以和字典一样,肯定有其它字段充当 ob_size。
Py_ssize_t fill
Active 态的 entry 数量加上 Dummy 态的 entry 数量。一个 entry 就是哈希表里的一个元素,类型为 setentry,因此在集合里面,一个 entry 就是一个 setentry 结构体实例。当删除集合的 entry 时,也必须是伪删除,因为要保证探测链不断裂。如果 entry 被伪删除了,那么它便处于 Dummy 态。
Py_ssize_t used
Active 态的 entry 数量,显然这个 used 充当了 ob_size,也就是集合的元素个数;
Py_ssize_t mask
在看字典源码的时候,我们也见到了 mask,它用于和哈希值进行按位与、计算索引,并且这个 mask 等于哈希表的容量减 1,为什么呢?假设哈希值等于 v,哈希表容量是 n,那么通过 v 对 n 取模即可得到一个位于 0 到 n-1 之间的数。但是取模运算的效率不高,而 v&(n-1) 的作用等价于 v%n,并且速度更快,所以 mask 的值要等于哈希表的容量减 1。但是注意,只有在 n 为 2 的幂次方的时候,v&(n-1) 和 v%n 才是完全等价的,所以哈希表的容量要求是 2 的幂次方,就是为了将取模运算优化成按位与运算。
setentry *table
指向 setentry 数组首元素的指针,这个 setentry 数组可以是下面的 smalltable,也可以是单独申请的一块内存;
Py_hash_t hash
集合的哈希值,只适用于不可变集合;
Py_ssize_t finger
用于 pop 方法;
setentry smalltable[8]
一个 setentry 类型的数组,集合的元素就存在里面。但我们知道,变长对象的内部不会存储具体的元素,而是会存储一个指针,该指针指向的内存区域才是用来存储具体元素的。这样当扩容的时候,只需要让指针指向新的内存区域即可,从而方便维护。没错,对于集合而言,只有在容量不超过 8 的时候,元素才会存在里面;而一旦超过了 8,那么会使用 malloc 单独申请内存;
weakreflist
弱引用列表,不做深入讨论;
有了字典的经验,再看集合会简单很多。然后是 setentry,用于承载集合内的元素,那么它的结构长什么样呢?相信你能够猜到。
// Include/cpython/setobject.h #define PySet_MINSIZE 8 typedef struct { PyObject *key; Py_hash_t hash; } setentry;
相比字典少了一个 value,这是显而易见的。
因此集合的结构很清晰了,假设有一个集合 {3.14, "abc", 666},那么它的结构如下:
由于集合里面只有三个元素,所以它们都会存在 smalltable 数组里面,我们通过 ctypes 来证明这一点。
from ctypes import * class PyObject(Structure): _fields_ = [ ("ob_refcnt", c_ssize_t), ("ob_type", c_void_p), ] class SetEntry(Structure): _fields_ = [ ("key", POINTER(PyObject)), ("hash", c_longlong) ] class PySetObject(PyObject): _fields_ = [ ("fill", c_ssize_t), ("used", c_ssize_t), ("mask", c_ssize_t), ("table", POINTER(SetEntry)), ("hash", c_long), ("finger", c_ssize_t), ("smalltable", (SetEntry * 8)), ("weakreflist", POINTER(PyObject)), ] s = {3.14, "abc", 666} # 先来打印一下哈希值 print('hash(3.14) =', hash(3.14)) print('hash("abc") =', hash("abc")) print('hash(666) =', hash(666)) """ hash(3.14) = 322818021289917443 hash("abc") = 8036038346376407734 hash(666) = 666 """ # 获取 PySetObject 结构体实例 py_set_obj = PySetObject.from_address(id(s)) # 遍历 smalltable,打印索引和 key 的哈希值 for index, entry in enumerate(py_set_obj.smalltable): print(index, entry.hash) """ 0 0 1 0 2 666 3 322818021289917443 4 0 5 0 6 8036038346376407734 7 0 """
根据输出的哈希值我们可以断定,这三个元素确实存在了 smalltable 数组里面,并且 666 存在了数组索引为 2 的位置、3.14 存在了数组索引为 3 的位置、"abc" 存在了数组索引为 6 的位置。
当然,由于哈希值是随机的,所以每次执行之后打印的结果都可能不一样,但是整数除外,它的哈希值就是它本身。既然哈希值不一样,那么每次映射出的索引也可能不同,但总之这三个元素是存在 smalltable 数组里面的。
然后我们再考察一下其它的字段:
s = {3.14, "abc", 666} py_set_obj = PySetObject.from_address(id(s)) # 集合里面有 3 个元素,所以 fill 和 used 都是 3 print(py_set_obj.fill) # 3 print(py_set_obj.used) # 3 # 将集合元素全部删除 # 这里不能用 s.clear(),原因一会儿说 for _ in range(len(s)): s.pop() # 我们知道哈希表在删除元素的时候是伪删除 # 所以 fill 不变,但是 used 每次会减 1 print(py_set_obj.fill) # 3 print(py_set_obj.used) # 0
fill 字段维护的是 Active 态的 entry 数量加上 Dummy 态的 entry 数量,所以删除元素时它的大小是不变的。但 used 字段的值每次会减 1,因为它维护的是 Active 态的 entry 的数量。所以在不涉及元素的删除时,这两者的大小是相等的。
另外我们说上面不能用 s.clear(),因为该方法表示清空集合,此时会重置为初始状态,然后 fill 和 used 都会是 0,这样就观察不到想要的现象了。
删除集合所有元素之后,我们再往里面添加元素,看看是什么效果:
s = {3.14, "abc", 666} py_set_obj = PySetObject.from_address(id(s)) for _ in range(len(s)): s.pop() # 添加一个元素 s.add(0) print(py_set_obj.fill) # 3 print(py_set_obj.used) # 1
多次执行的话,会发现打印的结果可能是 3、1,也有可能是 4、1。至于原因,有了字典的经验,相信你肯定能猜到。
首先添加元素之后,used 肯定为 1。至于 fill,如果添加元素的时候,正好撞上了一个 Dummy 态的 entry,那么将其替换掉,此时 fill 不变,仍然是 3。但如果没有撞上 Dummy 态的 entry,而是添加在了新的位置,那么 fill 就是 4。
for i in range(1, 10): s.add(i) print(py_set_obj.fill) # 10 print(py_set_obj.used) # 10 s.pop() print(py_set_obj.fill) # 10 print(py_set_obj.used) # 9
在之前代码的基础上,继续添加 9 个元素,然后 used 变成了 10,这很好理解,因为此时集合有 10 个元素。但 fill 也是 10,这是为什么?很简单,因为哈希表扩容了,扩容时会删除 Dummy 态的 entry,所以 fill 和 used 是相等的。同理,如果再继续 pop,那么 fill 和 used 就又变得不相等了。
集合的创建
集合的结构我们已经清楚了,再来看看它的初始化过程。我们调用类 set,传入一个可迭代对象,便可创建一个集合,这个过程是怎样的呢?
// Objects/setobject.c PyObject * PySet_New(PyObject *iterable) { return make_new_set(&PySet_Type, iterable); } static PyObject * make_new_set(PyTypeObject *type, PyObject *iterable) { assert(PyType_Check(type)); PySetObject *so; // 为 PySetObject 申请内存,初始容量为 8 so = (PySetObject *)type->tp_alloc(type, 0); if (so == NULL) return NULL; // 对字段做初始化 so->fill = 0; so->used = 0; so->mask = PySet_MINSIZE - 1; // 哈希表容量为 8 时,元素会存在 smalltable 里面 // 因此直接将 smalltable 赋值给 table so->table = so->smalltable; so->hash = -1; so->finger = 0; so->weakreflist = NULL; if (iterable != NULL) { // 遍历 iterable,将迭代出的元素添加到集合中 // 关于这个函数,我们之后再介绍 if (set_update_internal(so, iterable)) { Py_DECREF(so); return NULL; } } return (PyObject *)so; }
可以看到,集合的创建过程非常简单。
字典和集合的哈希表的差异
字典和集合都是采用哈希表实现的,但字典的哈希表使用了两个数组,而集合的哈希表使用了一个数组,我们对比一下两者的差异。
假设有一个字典和一个集合,字典包含三个键值对,分别是 "a": 1、"b": 2、"c": 3,集合包含三个元素,分别是 "a"、"b"、"c",然后映射出的索引分别是 2、5、3。
注:为了方便,这里的图画得没有那么严谨。比如集合的哈希表,里面的元素直接用字符串代替了,但其实它存储的是 setentry entry,而 entry 的 key 字段指向的才是字符串。当然这里我们心里清楚就好。
在介绍字典的时候我们说过,早期的字典内部的哈希表也是使用一个数组实现,除了 entry 会多存储一个 value 之外,其它和当前的集合是类似的。
但如果只使用一个数组实现,会导致内存浪费严重,因为哈希表必须要保证一定的稀疏性。所以后续字典内部的哈希表采用两个数组实现,将存储键值对的数组的长度压缩到原来的 2/3,至于映射出的索引则由另一个数组(哈希索引数组)来承载。
虽然引入新的数组会带来额外的内存开销(假设大小为 m 字节),但存储键值对的数组不用再浪费 1/3 的空间(假设大小为 n 字节),只要 m 小于 n,那么使用两个数组就会更加节省内存。而在介绍字典的时候我们也看到了,m 是远小于 n 的。
那么问题来了,为什么集合不使用两个数组呢?很简单,因为使用一个数组实现哈希表会更简单,虽然也更加浪费内存。而集合和字典在哈希表的实现上之所以区别对待,还是使用频率的问题,解释器内部极度依赖字典,比如全局变量就是使用字典存储的。
可以说字典的效率高度影响着整个解释器的效率,字典的内存大小高度影响着解释器的内存占用。因此 Python 除了优化字典的搜索性能之外,还要尽可能地减少字典的内存大小。所以字典搞出了分离表、结合表,以及根据 key 是否全部是字符串来选择使用不同的结构体表示 entry,这一切操作都是为了将字典的内存占用降到最低。
至于集合,解释器对它的依赖就很小了,所以内部的哈希表,只采用了一个数组实现。虽然会有内存浪费,但无伤大雅。
好,回到上面的例子,如果将字典的键值对 "b": 2 和集合的元素 "b" 删掉,那么它们的结构会发生什么变化呢?
"b" 映射出的索引为 5,因此对于字典来说,会将索引为 5 的哈希槽的值设置为 dummy。然后是键值对数组,会将指定的 entry 的 me_key 和 me_value 字段全部设置为 NULL,相当于回归到了初始状态。
需要注意的是,数组一旦申请,那么 entry 的空间就已经有了,只是 me_key 和 me_value 字段均为 NULL。而所谓添加键值对,本质上也是修改指定 entry 的 me_key 和 me_value 字段。
对于集合来说,它只有一个数组,这个数组不仅要存储键值对,它的索引还表示 key 映射出的索引,当然这里的 key 指的就是集合的元素。"b" 映射出的索引为 5,所以将数组中索引为 5 的 entry->key 设置为 dummy。
但要注意的是,字典的 dummy 是一个整数,值为 -2(DKIX_DUMMY),因为哈希索引数组存储的是整数。key 映射出的索引是哈希索引数组的索引,如果对应的哈希槽存储的值是 -2,说明当前搜索的 key 对应的 entry 被删除了,应该继续向后搜索。
而集合的 dummy 是一个结构体指针,定义如下:
// Objects/setobject.c static PyObject _dummy_struct; #define dummy (&_dummy_struct)
因为集合内部的哈希表只使用了一个数组,该数组存储的是 setentry。如果在查找的时候,发现对应的 entry 的 key 等于 dummy,就知道该 entry 被删除了,应该继续向后搜索。
好,继续回到上面的例子,假设这时候再给字典添加一个键值对 "d": 4,给集合添加一个元素 "d",而字符串 "d" 映射出的索引也是 5,那么结构是怎样的呢?
对于字典来说,键值对始终按照先来后到的顺序添加在键值对数组中,然后将它在键值对数组中的索引保存在指定的哈希槽中。由于索引为 5 的哈希槽保存的是 -2,处于 Dummy 态,因此直接将它设置为 3。
同理对于集合来说也是类似的。数组索引为 5 的位置保存的值等于 dummy,处于 Dummy 态,说明该元素被删除了,那么直接替换掉。因此整个过程的逻辑很简单:由于索引会存在冲突,所以元素删除之后,需要写入一个特殊的墓碑值,也就是这里的 dummy,因为要保证探测链不断裂。但如果集合后续添加元素时,正好撞上了一个 Dummy 态的 entry,那么会直接替换掉。
所以不论是字典还是集合,只要处于 Dummy 态,都可以替换掉。因为 Dummy 态存在的目的就是为了保证探测链不断裂,而替换之后探测链依旧是完整的。
小结
以上我们就剖析了集合的底层结构以及它的创建过程,不难发现集合的实现比字典要简单很多,并且集合没有自己的缓存池。
下一篇文章来介绍集合的相关操作。