Python 中 dict 的 keys 视图动态性排序时报错的规避写法

很多人第一次碰到 TypeError: ' 或者 RuntimeError: dictionary changed size during iteration,都是在给 dict.keys() 做排序、切片、索引的时候。表面上看 d.keys() 像一个列表,实际它是 视图对象(view object),不持有数据副本,而是实时反映原字典的结构。这个特性在遍

很多人第一次碰到 TypeError: '<' not supported between instances of ... 或者 RuntimeError: dictionary changed size during iteration,都是在给 dict.keys() 做排序、切片、索引的时候。表面上看 d.keys() 像一个列表,实际它是 视图对象(view object),不持有数据副本,而是实时反映原字典的结构。这个特性在遍历时很方便,一旦你把它当静态序列用,就会踩坑。下面把成因、报错原文、以及工程上真正可靠的几种写法讲清楚。

一、keys() 返回的到底是什么:视图而非快照

从 Python 3 开始,dict.keys()、dict.values()、dict.items() 都返回视图对象,类型分别是 dict_keys、dict_values、dict_items。它们实现了 collections.abc.KeysView 等抽象基类,支持 len()、in 成员检测、集合运算(&、|、-、^),但不支持下标访问,也不支持 sort() 就地排序。

d = {"banana": 3, "apple": 1, "cherry": 2}

ks = d.keys()
print(type(ks))        # <class 'dict_keys'>
print(len(ks))         # 3
print("apple" in ks)   # True

# 下面两行都会报错
# ks[0]                # TypeError: 'dict_keys' object is not subscriptable
# ks.sort()            # AttributeError: 'dict_keys' object has no attribute 'sort'

关键点在“动态性”:视图不复制键,它只是原字典的一个窗口。字典增删键,视图立刻跟着变。

d = {"a": 1, "b": 2}
ks = d.keys()
print(list(ks))   # ['a', 'b']

d["c"] = 3
print(list(ks))   # ['a', 'b', 'c']  —— 视图同步变化

del d["a"]
print(list(ks))   # ['b', 'c']

正因为视图不是快照,sorted(d.keys()) 与 sorted(list(d.keys())) 在结果上等价,但前者不会在排序过程中对原字典做任何保护;如果你在排序的 key 函数里顺手修改了字典,问题就来了——下一节会说。

二、报错现场:三种典型错误与原始信息

2.1 RuntimeError:排序期间字典被改动

d = {"a": 1, "b": 2, "c": 3}

def key_func(k):
    d[k + "_new"] = 0     # 排序过程中给字典加键
    return k

try:
    sorted(d.keys(), key=key_func)
except RuntimeError as e:
    print(repr(e))

实际输出:

RuntimeError: dictionary changed size during iteration

sorted() 内部会先对可迭代对象做一次迭代取元素,而 dict_keys 的迭代器带有“已遍历元素数”校验,一旦发现字典 ma_used(有效条目数)变化就抛错。注意如果只是修改已有键的值,不会触发这个异常;只有增删键才会。

d = {"a": 1, "b": 2}
for k in d.keys():
    d[k] = 0          # 只改值,安全,不会报错
print(d)              # {'a': 0, 'b': 0}

d = {"a": 1, "b": 2}
try:
    for k in d.keys():
        d[k.upper()] = 0    # 新增键,立即报错
except RuntimeError as e:
    print(repr(e))
RuntimeError: dictionary changed size during iteration

2.2 TypeError:键不是同一种可比较类型

这个错误跟视图无关,是 Python 3 严格类型比较的结果,但常被误认为是 keys 视图的问题。

d = {"a": 1, 2: 2, (3, 4): 3}
try:
    sorted(d.keys())
except TypeError as e:
    print(repr(e))
TypeError: '<' not supported between instances of 'int' and 'str'

把纯 dict_keys 传进去,报错信息里出现的仍是具体元素类型,而不是 dict_keys,这也说明 sorted() 已经成功迭代出了元素,卡在比较阶段。

2.3 TypeError:对 values() 排序时元素不可比

d = {"x": [1, 2], "y": [3]}
try:
    sorted(d.values())
except TypeError as e:
    print(repr(e))
TypeError: '<' not supported between instances of 'list' and 'list'

列表之间其实可以用 < 比较(按字典序),报错通常发生在元素是 dict、自定义对象或缺 __lt__ 的实例时。上面这段在标准实现里会正常返回,因此排查时不要凭“列表不能比”下结论,直接看 traceback 末行给出的两个类型名。

三、五种可靠规避写法与适用场景

3.1 先物化成快照再排序(最常用)

d = {"banana": 3, "apple": 1, "cherry": 2}

snapshot = list(d.keys())          # 复制一份,之后随便改 d
snapshot.sort(reverse=True)
print(snapshot)                     # ['cherry', 'banana', 'apple']
print(d)                            # 原字典顺序不受影响

快照的创建开销是 O(n),对普通规模的字典完全可接受。要排序后又按下标取,例如取 Top N,必须先物化:

d = {"banana": 3, "apple": 1, "cherry": 2, "date": 4}

top2 = sorted(d.keys(), key=lambda k: d[k], reverse=True)[:2]
print(top2)   # ['date', 'banana']

这里 sorted() 本身返回 list,所以能用切片,不需要额外 list();key=lambda k: d[k] 读字典值,只要不在里面写赋值就不触发结构变化。

3.2 用 items() 排序,一步拿到键值对

d = {"banana": 3, "apple": 1, "cherry": 2}

by_value = sorted(d.items(), key=lambda kv: kv[1])
print(by_value)          # [('apple', 1), ('cherry', 2), ('banana', 3)]

by_key_then_value = sorted(d.items(), key=lambda kv: (kv[0], kv[1]))
print(by_key_then_value)

如果需要同时按多个字段排序,key 函数返回元组即可;元组内元素类型仍需一致,否则回到 2.2 的 TypeError。

3.3 迭代时改字典:循环体外先固定键集合

d = {"a": 1, "b": 2, "c": 3}

for k in list(d.keys()):       # 拷贝后迭代,循环体内可安全增删
    if d[k] & 1:
        d[k + "_odd"] = d[k]
print(d)   # {'a': 1, 'b': 2, 'c': 3, 'a_odd': 1, 'c_odd': 3}

等价写法还有 set(d)、tuple(d),以及 for k in d.copy():。不要写成 for k in d: 同时删除元素:

d = {"a": 1, "b": 2}
try:
    for k in d:
        del d[k]
except RuntimeError as e:
    print(repr(e))
RuntimeError: dictionary changed size during iteration

正确做法是倒序删除,或者一次性构造新字典:

d = {"a": 1, "b": 2, "c": 3}

# 写法 A:倒序删除,索引稳定
for k in sorted(d, reverse=True):
    del d[k]
print(d)   # {}

# 写法 B:字典推导重建,语义最清晰
d = {"a": 1, "b": 2, "c": 3}
d = {k: v for k, v in d.items() if v > 1}
print(d)   # {'b': 2, 'c': 3}

3.4 排序逻辑里不碰原字典,把修改挪到排序之后

d = {"b": 2, "a": 1, "c": 3}

# 反例:key 函数里写 d[...] = ...
# 正例:先算出顺序,再统一写入
order = sorted(d, key=lambda k: -d[k])
print(order)   # ['c', 'b', 'a']

for idx, k in enumerate(order):
    d[f"rank_{idx}"] = k
print(d)

这条原则可以概括为:排序、遍历这类“读取过程”保持只读,所有写操作放到过程结束之后。它适用于 dict,也适用于 set、list 等所有容器视图。

3.5 需要静态语义就换成 OrderedDict 或普通 dict 副本

from collections import OrderedDict

od = OrderedDict([("banana", 3), ("apple", 1)])
od["cherry"] = 2
print(list(od.keys()))          # ['banana', 'apple', 'cherry']

# 注意:OrderedDict.keys() 依然是动态视图
ks = od.keys()
od["date"] = 4
print(list(ks))                 # ['banana', 'apple', 'cherry', 'date']

Python 3.7 起普通 dict 已保证插入顺序,OrderedDict 的价值主要在 move_to_end() 和相等性比较语义上,并不能让 keys() 变成快照。真正需要“冻结”就显式 list() 或 tuple()。

四、写排序 key 时的两个细节

4.1 混合类型键的稳妥排序

d = {"a": 1, 2: 2, (3, 4): 3}

def type_then_value(k):
    return (type(k).__name__, str(k))

print(sorted(d, key=type_then_value))
# [2, (3, 4), 'a']  —— 按类型名字典序,再按字符串

str(k) 对任意对象都能工作,代价是数字按字符串比较("10" < "9")。若全是数字键就老老实实 sorted(d),不要画蛇添足。

4.2 None 值参与比较

d = {"a": 3, "b": None, "c": 1}

# 反例会抛 TypeError
# sorted(d, key=lambda k: d[k])
# TypeError: '<' not supported between instances of 'NoneType' and 'int'

# 正例:把 None 统一视为最小值
print(sorted(d, key=lambda k: (d[k] is None, d[k])))
# ['b', 'c', 'a']

(d[k] is None, d[k]) 这个技巧依赖“False < True”,先按是否为 None 分组,再组内比较真实值。注意组内若还混有多种类型,仍需再做一次归一化。

五、小结

  • dict.keys() 是视图,不是列表;动态反映原字典,且不支持 sub 下标与 sort()。
  • 要排序就先 sorted() 或 list(),sorted() 本身已返回 list,可以直接切片。
  • 迭代期间增删键会触发 RuntimeError: dictionary changed size during iteration,改值则不会。
  • 混合类型键或 None 值参与比较会出现 TypeError: '<' not supported between instances of ...,需要用 key 函数做类型归一。
  • 核心原则:读取过程只读,写操作统一放到过程之后。

常见问题(FAQ)

Q1:sorted(d.keys()) 和 sorted(d) 有区别吗?
结果完全一致,因为 dict 的迭代协议就是迭代键。sorted(d) 少一次 keys() 调用,可读性也够,推荐后者。

Q2:d.keys() == ["a", "b"] 为什么是 False?
dict_keys 与 list 不同源,相等性比较只在视图与集合类对象间按集合语义进行。要比较序列内容请先 list(d.keys()) == ["a", "b"]。

Q3:视图能不能转成 set 再排序?
可以,sorted(set(d.keys())),但要注意这会去重——键本身在 dict 里必然唯一,所以结果不变,只是多了一次哈希开销,一般没必要。

Q4:为什么用 pandas 或 json 序列化时顺序又乱了?
那是下游库自己的排序规则(例如 json.dumps(..., sort_keys=True) 或列索引重建),与 dict_keys 视图无关,按库的参数控制即可。

Q5:多线程环境下视图安全吗?
CPython 的 dict 增删在单条字节码层面受 GIL 保护,但“检查后再删除”不是原子操作。并发修改仍可能触发上述 RuntimeError,需要用锁,或按 3.3 的方案在锁内生成快照。