解密 Python 集合的实现原理

简介: 解密 Python 集合的实现原理


楔子


本篇文章来聊一聊 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},那么它的结构如下:

81eebffec14cd7b55d6640961b8277b3.png

由于集合里面只有三个元素,所以它们都会存在 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。

1e8d1e8c292a5155e7c22fdee56f9fc1.png

注:为了方便,这里的图画得没有那么严谨。比如集合的哈希表,里面的元素直接用字符串代替了,但其实它存储的是 setentry entry,而 entry 的 key 字段指向的才是字符串。当然这里我们心里清楚就好。

在介绍字典的时候我们说过,早期的字典内部的哈希表也是使用一个数组实现,除了 entry 会多存储一个 value 之外,其它和当前的集合是类似的。

但如果只使用一个数组实现,会导致内存浪费严重,因为哈希表必须要保证一定的稀疏性。所以后续字典内部的哈希表采用两个数组实现,将存储键值对的数组的长度压缩到原来的 2/3,至于映射出的索引则由另一个数组(哈希索引数组)来承载。

虽然引入新的数组会带来额外的内存开销(假设大小为 m 字节),但存储键值对的数组不用再浪费 1/3 的空间(假设大小为 n 字节),只要 m 小于 n,那么使用两个数组就会更加节省内存。而在介绍字典的时候我们也看到了,m 是远小于 n 的。

那么问题来了,为什么集合不使用两个数组呢?很简单,因为使用一个数组实现哈希表会更简单,虽然也更加浪费内存。而集合和字典在哈希表的实现上之所以区别对待,还是使用频率的问题,解释器内部极度依赖字典,比如全局变量就是使用字典存储的。

可以说字典的效率高度影响着整个解释器的效率,字典的内存大小高度影响着解释器的内存占用。因此 Python 除了优化字典的搜索性能之外,还要尽可能地减少字典的内存大小。所以字典搞出了分离表、结合表,以及根据 key 是否全部是字符串来选择使用不同的结构体表示 entry,这一切操作都是为了将字典的内存占用降到最低。

至于集合,解释器对它的依赖就很小了,所以内部的哈希表,只采用了一个数组实现。虽然会有内存浪费,但无伤大雅。

好,回到上面的例子,如果将字典的键值对 "b": 2 和集合的元素 "b" 删掉,那么它们的结构会发生什么变化呢?

58f52bc830874664177977addb005981.png

"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,那么结构是怎样的呢?

d71b913dfbad38b15ce5a2e0a9601f67.png

对于字典来说,键值对始终按照先来后到的顺序添加在键值对数组中,然后将它在键值对数组中的索引保存在指定的哈希槽中。由于索引为 5 的哈希槽保存的是 -2,处于 Dummy 态,因此直接将它设置为 3。

同理对于集合来说也是类似的。数组索引为 5 的位置保存的值等于 dummy,处于 Dummy 态,说明该元素被删除了,那么直接替换掉。因此整个过程的逻辑很简单:由于索引会存在冲突,所以元素删除之后,需要写入一个特殊的墓碑值,也就是这里的 dummy,因为要保证探测链不断裂。但如果集合后续添加元素时,正好撞上了一个 Dummy 态的 entry,那么会直接替换掉。

所以不论是字典还是集合,只要处于 Dummy 态,都可以替换掉。因为 Dummy 态存在的目的就是为了保证探测链不断裂,而替换之后探测链依旧是完整的。


小结


以上我们就剖析了集合的底层结构以及它的创建过程,不难发现集合的实现比字典要简单很多,并且集合没有自己的缓存池。

下一篇文章来介绍集合的相关操作。

相关文章
|
2天前
|
存储 缓存 Python
解密 Python 元组的实现原理
解密 Python 元组的实现原理
19 4
|
10天前
|
索引 Python
解密 Python 迭代器的实现原理
解密 Python 迭代器的实现原理
35 13
|
10天前
|
存储 索引 Python
深度解密 Python 列表的实现原理
深度解密 Python 列表的实现原理
40 13
|
10天前
|
存储 API 索引
Python 的集合是怎么实现的?
Python 的集合是怎么实现的?
37 9
|
12天前
|
存储 索引 Python
Python常用数据结构——集合
Python常用数据结构——集合
31 3
|
2天前
|
存储 IDE 编译器
解密 Python 函数的实现原理
解密 Python 函数的实现原理
7 0
|
1月前
|
存储 Java 开发者
python脚本实现原理
【9月更文挑战第4天】python脚本实现原理
33 5
|
12天前
|
存储 数据处理 Python
Python中的Set集合:高效数据处理的利器
Python中的Set集合:高效数据处理的利器
21 0
|
14天前
|
Python
python推导式-列表,元组,字典,集合推导式
这篇文章介绍了Python中的推导式,包括列表推导式、元组推导式、字典推导式和集合推导式,提供了它们的基本格式和示例代码,并解释了推导式如何简化循环和条件判断的代码编写。
|
2月前
|
索引 Python 容器
为什么Python中会有集合set类型?
为什么Python中会有集合set类型?