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。

相关推荐
椰萝Yerosius43 分钟前
严正声明 | 博主已正式放弃CSDN作为博客平台
python
肖永威1 小时前
python版微电网仿真工具的前端托管选型:从http.server到FastAPI StaticFiles
前端·python·http·fastapi
lg_cool_1 小时前
Python 代码打包
开发语言·python
2601_962885721 小时前
如何用 Python 做 MACD 金叉全市场选股?
开发语言·python
科技研学社2 小时前
服装厂激光开袋机与智能模板机怎么选?核心工序设备深度对比与落地实战
数据库·python
张张123y6 小时前
后端第十四课:Agent 记忆系统(让 Agent 记住上下文和用户,跨会话不健忘)
java·大数据·人工智能·python·agent·记忆
happylifetree10 小时前
Python67:核心语法-函数进阶-参数类型(函数作为参数)
python
小羊没烦恼!11 小时前
铁道部新客票系统设计(二)
开发语言·前端·数据库·python·oracle·c#·php
quantdash_cc12 小时前
从实时行情到价格观察表:用 Python 和 Pandas 构建策略输入数据
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash