1. 引言
Python 的字典(dict)是这门语言最核心、最常用的数据结构之一。无论是模块的命名空间、对象的属性查找,还是函数的关键字参数传递,背后都离不开字典的身影。正因如此,CPython 对字典的实现进行了多轮深度优化,使其在保持灵活性的同时,也能拥有出色的性能。
本文将深入 CPython 源码,剖析字典从早期版本到现代版本(Python 3.6+)的演进历程,重点讲解紧凑布局(compact layout) 、哈希表结构 、冲突解决策略以及**键共享(key-sharing)**等关键优化技术,帮助你真正理解 Python 字典为什么快、快在哪里。
2. 字典的基础:哈希表
字典本质上是一张哈希表(hash table)。它通过哈希函数将键(key)映射到一个固定大小的数组索引上,从而实现平均 O(1) 的查找、插入和删除。
2.1 哈希函数与哈希值
Python 中每个对象都可以通过 hash() 函数获取一个整数哈希值。对于整数,哈希值就是它本身(取模后);对于字符串,CPython 使用 SipHash 算法计算;对于自定义对象,则调用其 __hash__ 方法。
python
>>> hash("hello")
-1182516015910
>>> hash(42)
42
需要注意的是,哈希值并不是直接作为数组下标使用的,而是需要经过一步**取模(mod)**运算,映射到哈希表的容量范围内。
2.2 冲突与解决
当两个不同的键计算出相同的数组索引时,就发生了哈希冲突(collision)。CPython 采用**开放寻址法(open addressing)**来解决冲突:当某个槽位已被占用时,会按照一定的探测序列(probe sequence)继续向后查找,直到找到空槽或匹配的键。
python
# 伪代码:开放寻址的探测过程
def find_slot(table, key):
index = hash(key) % len(table)
while table[index] is not None and table[index].key != key:
index = (index + 1) % len(table) # 线性探测
return index
CPython 实际使用的探测策略比简单的线性探测更精细,它基于哈希值的高位信息来生成探测步长,从而减少聚集(clustering)现象。
3. 传统实现:分离式存储(Python 3.5 及以前)
在 Python 3.5 及更早版本中,字典的底层结构是一个单一的 entries 数组,每个 entry 同时存储哈希值、键和值:
c
typedef struct {
Py_hash_t me_hash; // 哈希值
PyObject *me_key; // 键
PyObject *me_value; // 值
} PyDictEntry;
整个字典就是这样一个 entry 数组,加上一些元信息(容量、已用数量等)。
3.1 传统实现的问题
这种实现有一个明显的缺点:内存利用率低。为了保证查找效率,哈希表的负载因子(load factor)通常维持在 2/3 以下,也就是说有大约 1/3 的槽位是空的。在传统实现中,这些空槽位同样占用完整的 entry 空间(每个 entry 24 字节),造成了大量内存浪费。
此外,遍历字典时,需要扫描整个数组,跳过所有空槽位,效率也不高。
4. 现代实现:紧凑布局(Python 3.6+)
Python 3.6 引入了一项革命性的优化------紧凑字典(compact dict) ,由 INADA Naoki 提出。这一设计将索引 与数据分离,从根本上解决了内存浪费问题。
4.1 双数组结构
现代字典由两个数组组成:
- 索引数组(indices) :一个
int数组,长度等于哈希表的容量(通常是 2 的幂),存储的是 entry 在 entries 数组中的下标。 - entries 数组:一个紧凑的 entry 数组,只包含实际存在的键值对,按插入顺序排列。
c
typedef struct {
PyObject *me_key; // 键
PyObject *me_value; // 值
Py_hash_t me_hash; // 哈希值
} PyDictKeyEntry;
4.2 查找过程
查找时,先通过哈希值定位到 indices 数组中的某个槽位,取出对应的 entry 下标,再到 entries 数组中访问真正的键值对:
python
# 伪代码:紧凑字典的查找
def lookup(d, key):
idx = hash(key) % len(d.indices)
entry_index = d.indices[idx]
if entry_index == -1: # 空槽
return None
entry = d.entries[entry_index]
if entry.key == key:
return entry.value
# 冲突则继续探测
4.3 内存节省
由于 indices 数组只存储整数下标(每个 1~8 字节,视容量而定),而 entries 数组只存储实际存在的键值对,空槽位不再占用完整的 entry 空间。对于一个小字典(如 8 个键值对),内存占用可以从传统实现的数百字节降低到几十字节,节省幅度可达数倍。
4.4 保持插入顺序
紧凑布局的另一个副产品是:字典现在保持插入顺序。因为 entries 数组按插入顺序追加,遍历时只需顺序扫描 entries 数组即可,无需跳过空槽。这一特性在 Python 3.7 中被正式写入语言规范,成为所有 Python 实现必须遵守的行为。
python
>>> d = {"b": 1, "a": 2, "c": 3}
>>> list(d.keys())
['b', 'a', 'c'] # 保持插入顺序
5. 键共享字典(Key-Sharing Dict)
在 Python 3.3 中,CPython 引入了键共享字典(key-sharing dict),专门用于优化对象属性存储。
5.1 问题背景
每个 Python 对象都有一个 __dict__ 属性字典,用于存储实例属性。如果每个实例都拥有一份独立的、完整的字典结构,那么对于大量同类的实例对象,会浪费大量内存------因为它们的键(属性名)几乎完全相同,只有值不同。
5.2 共享机制
键共享字典的核心思想是:将键(属性名)和哈希值提取出来,放到一个共享的 keys 对象中,所有同类的实例共用这一份 keys;每个实例只保存自己的 values 数组。
c
typedef struct {
Py_ssize_t dk_refcnt; // 引用计数
Py_ssize_t dk_size; // 容量
PyDictKeyEntry *dk_entries; // 共享的键和哈希值
// ...
} PyDictKeysObject;
实例对象的结构变为:
c
typedef struct {
PyObject_HEAD
PyDictKeysObject *ma_keys; // 指向共享的 keys
PyObject **ma_values; // 本实例的值数组
} PyDictObject;
5.3 收益
对于大量同类实例(例如 ORM 模型、数据类实例),键共享字典可以显著减少内存占用。假设有 10000 个实例,每个有 5 个属性,传统实现需要 10000 份完整的字典结构;而键共享实现只需要 1 份 keys + 10000 份 values 数组,内存节省非常可观。
python
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
# 大量 Point 实例共享同一份 keys("x", "y")
points = [Point(i, i * 2) for i in range(10000)]
5.4 注意事项
键共享字典有一个限制:一旦某个实例的字典被直接修改(例如添加了新的键),该实例就会脱离共享,转为独立的完整字典 。因此,在性能敏感的场景中,应尽量避免对实例的 __dict__ 进行动态增删键的操作。
6. 其他优化细节
6.1 哈希值缓存
对于字符串键,CPython 会缓存其哈希值。字符串对象内部有一个字段用于存储计算过的哈希值,这样同一个字符串在多次作为字典键查找时,无需重复计算哈希。
c
typedef struct {
PyObject_HEAD
Py_ssize_t ob_shash; // 缓存的哈希值,-1 表示未计算
// ...
} PyUnicodeObject;
6.2 小字典的快速路径
CPython 对容量较小的字典(如 1~5 个键值对)提供了专门的快速路径,避免通用查找逻辑的开销。这些小型字典在创建时直接使用栈上分配的固定大小数组,进一步减少内存分配次数。
6.3 调整大小策略
当字典的负载因子超过 2/3 时,字典会进行扩容,通常将容量翻倍。扩容时,indices 数组会重新分配,但 entries 数组中的键值对可以原地保留,只需重新计算每个 entry 在 indices 中的映射关系,这比传统实现中整体搬迁 entry 数组要高效得多。
7. 性能对比与实测
为了直观感受这些优化带来的收益,我们可以做一个简单的内存对比实验:
python
import sys
# 传统方式:大量独立小字典
dicts = [{"a": i, "b": i + 1, "c": i + 2} for i in range(10000)]
print(sys.getsizeof(dicts[0])) # 单个字典的大小
# 键共享方式:大量同类实例
class Obj:
def __init__(self, a, b, c):
self.a = a
self.b = b
self.c = c
objs = [Obj(i, i + 1, i + 2) for i in range(10000)]
print(sys.getsizeof(objs[0].__dict__)) # 单个实例字典的大小
在 Python 3.11 中运行上述代码,你会发现单个实例的 __dict__ 大小远小于独立字典的大小,这正是键共享优化的直接体现。
8. 总结
Python 字典的优化之路,是 CPython 在内存效率 与访问速度之间不断权衡的缩影:
- 紧凑布局将索引与数据分离,大幅降低内存占用,并顺带实现了插入有序;
- 键共享字典针对对象属性存储场景,让大量同类实例共享键结构,节省海量内存;
- 哈希值缓存 与小字典快速路径等细节优化,让常见操作更加高效。
理解这些底层机制,不仅能帮助你写出更高效的代码(例如避免破坏键共享、合理预估字典容量),也能让你在阅读 CPython 源码时更加游刃有余。字典虽小,却凝聚了 Python 设计者数十年的智慧。