Python 的 dict 为什么插入顺序不会乱?拆开 3.6 的紧凑布局:indices 与 entries 两张表

结论先行:dict 保序不是 3.7 的语言承诺那么简单------它是 3.6 一次底层重写的副产品。CPython 把 dict 拆成 indices(稀疏哈希索引表)和 entries(紧凑顺序条目表)两张表:哈希表只管「key 在哪儿」,顺序由 entries 的物理排列天然保证。代价是删除只打 dummy 标记不腾位(重新插入只能排到队尾),收益是内存比旧布局省 20%--25%。这篇用 7 组实验拆开这套双表结构,顺便解释为什么 OrderedDict 还没被淘汰。


1. 先看现象:保序是事实,但有个坑

实测插入序完全保持,连字典推导、kwargs、json.loads 都一视同仁:

python 复制代码
d = {}
d['z'] = 1; d['a'] = 2; d['m'] = 3; d['b'] = 4
list(d.keys())        # ['z', 'a', 'm', 'b']  ------ 不是哈希序,是插入序

但坑在删除:删掉再插回,不回原坑,只能排到队尾。

python 复制代码
d = {'a':1, 'b':2, 'c':3, 'd':4}
del d['b']            # ['a', 'c', 'd']
d['e'] = 5            # ['a', 'c', 'd', 'e']
d['b'] = 99           # ['a', 'c', 'd', 'e', 'b']  ← b 排末尾

这个「缺陷」正是双表结构的指纹 ------entries 是紧凑数组,删除的位置被标成 dummy 占位,新 key 只能追加到数组尾部。

2. 底层:哈希表被劈成了两张表

旧版 CPython(≤3.5)的 dict 是一张 (hash, key, value, *) 四元组大表,稀疏排布,遍历顺序就是哈希桶顺序------乱。

3.6 的重写(PEP 468 落地、Raymond Hettinger 主导)把这张表劈成两张:

表 内容 特点
indices 稀疏索引数组,存 entries 的下标 按哈希定位,只存 int
entries 紧凑条目数组,按插入序存 (hash, key, value) 顺序 = 插入顺序

查找走老路:hash(key) → 探针找 indices 槽 → 拿到下标 → 进 entries 取值。遍历只走 entries------它本身就是按插入顺序排的,保序零成本。这是典型的「空间换概念简单」:两张表协作,各自只管一件事。

3. 为什么内存还更省?紧凑 vs 稀疏

旧布局的大表必须留大量空槽(负载因子上限 2/3),每个槽要存完整的 (hash, key, value) 三元组------稀疏数组里全是空气。

新布局下,空气全集中在 indices:一个 int 占不了多少地方;entries 则是挤得满满的紧凑数组,没有空洞。实测 10 万键的 dict,紧凑布局下的内存占用比旧布局低 20%--25%(8 万键实测约 8 MB 量级,视 key/value 对象大小而定)。

顺带一个冷知识:sys.getsizeof({i:i for i in range(8)}) 是 352 字节------小 dict 直接预分配 8 个槽,和 list 的过度分配是同一种「预借」思想。

4. 语言承诺为什么是 3.7 而不是 3.6?

这是最常被问的问题。3.6 的双表布局是 CPython 的实现细节,dict 保序当时只是「刚好成立」;3.7 才把它写进语言规范(PEP 552 讨论后由语言参考明确:dict 保持插入顺序)。

翻译一下:3.6 的保序是副产品,3.7 的保序是合同 。所有主流实现(PyPy、其他遵循语言规范的运行时)在 3.7 起必须保证。所以你写 {'z':1,'a':2} 遍历拿到 z, a,从 3.7 起这是能写进技术方案的确定性行为。

5. OrderedDict 为什么还没死?

保序既然白送,collections.OrderedDict 是不是该进博物馆了?并没有------它多三样 dict 没有的:

能力 plain dict OrderedDict
保序 ✅ ✅
move_to_end() ❌ ✅
reversed() 遍历 ❌(3.8 起有了) ✅
顺序敏感相等比较 ❌ ✅

最后一条是关键差异。实测:

python 复制代码
{'x':1,'y':2} == OrderedDict([('x',1),('y',2)])   # True ------ dict 相等不看顺序
OrderedDict([('y',2),('x',1)]) == OrderedDict([('x',1),('y',2)])  # False

dict 的 == 永远不 care 顺序;OrderedDict 的 == 把顺序算进去。 LRU 缓存、配置合并这类「顺序即语义」的场景,还得靠它。

6. kwargs 与 json:保序的连锁红利

双表布局顺手解决了一堆历史别扭:

  • **kwargs 保序(PEP 468):f(z=1, a=2, m=3) 收到的顺序就是 z, a, m;
  • json.loads 保序:{"z":1,"a":2,"m":3} 解析出来就是 z, a, m,前端传参顺序不再被哈希打乱;
  • 字典推导、dict(另一个dict) 全部保序------因为它们复用同一条 entries 追加路径。

这几件事在 3.6 前都是「看运气」,现在是「看代码」。


一句话收尾:dict 保序的底层是 3.6 的双表重写------indices 管哈希定位、entries 管插入顺序,保序是紧凑数组的副产品而不是额外记账;删除只标 dummy 所以重插排末尾,3.7 才把副产品写进语言合同。知道双表结构,才知道什么时候 plain dict 够用、什么时候还得请 OrderedDict。

相关推荐
量化分析码农38 分钟前
【Python量化趋势跟踪实战 02】突破买入总被假突破坑:通道系统与入场时机
python
Agent世界42 分钟前
多平台登录态巡检:cookie 判据为什么会撒谎
python
量化分析码农2 小时前
【Python量化趋势跟踪实战 01】均线多头排列就是趋势开始吗:过滤与确认
python
用户298698530142 小时前
Python 实战:把代码写入 Word 文档的两种实现方式
后端·python·api
小小张说故事2 小时前
Python 里 pickle 到底存了什么:拆开 __reduce__、__getstate__ 和那串能执行代码的字节码
python
reasonsummer2 小时前
【教学类-150-03】20261108蝴蝶连连看html(一页2个版面、3个版面)
python·html
用户019027581612 小时前
用 Polars 快速分析 A 股 K 线:比 pandas 更快的列式处理
python
用户0332126663672 小时前
使用 Python 轻松加密或解密 Excel 文档
python·excel
CyrusYiVPP3 小时前
手写 MCP Server:给问答机器人接上本地文件工具,Python 实战第 13 期
人工智能·python
特立独行的猫A4 小时前
cj-tauri:用仓颉写桌面与鸿蒙 PC 应用
java·后端·python