结论先行: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。