Python WeakKeyDictionary 相等键:后来的对象还活着,为什么条目却跟着原键消失

前天 4阅读

给文档对象附加临时备注时,可以把对象作为WeakKeyDictionary的键,让备注随对象释放。但如果两个不同对象按业务编号比较相等,向第二个对象写入备注后,删除第一个对象仍可能让条目消失。关键不只是是否使用弱引用,还包括这个位置实际保存着哪一个键对象。

本例在CPython 3.12.14执行,只创建冻结的数据类与内存字典。保存为demo.py,用python3 demo.py运行。显式gc.collect用于让实验进入可观察的回收时点,不是建议业务在每次查字典后强制回收。

Python WeakKeyDictionary 相等键:后来的对象还活着,为什么条目却跟着原键消失

AI模型生成概念图:两把外观相近但独立的钥匙中,标签架只连着原来的一把,用于比喻相等键与持有对象身份的差别。

让相等与相同同时出现在样本中

DocumentKey按number生成相等和散列行为,frozen避免编号被随手改动。first与second的编号都是7,因此相等,却不是同一个实例。两次赋值后检查条目数量、读取的值和实际键身份,随后释放first。第二段再用编号9演示明确替换键的操作。

完整可运行程序

import gc
from dataclasses import dataclass
from weakref import WeakKeyDictionary, ref

@dataclass(frozen=True)
class DocumentKey:
    number: int

first = DocumentKey(7)
second = DocumentKey(7)
assert first == second and first is not second
notes = WeakKeyDictionary()
notes[first] = "draft"
notes[second] = "reviewed"
assert len(notes) == 1
assert next(iter(notes)) is first
print("equal key value:", notes[second])
print("stored key is first:", next(iter(notes)) is first)
watch = ref(first)
del first
gc.collect()
assert watch() is None and len(notes) == 0
print("second still alive:", second.number)
print("entries after first released:", len(notes))

third = DocumentKey(9)
fourth = DocumentKey(9)
notes[third] = "old"
del notes[third]
notes[fourth] = "new"
assert next(iter(notes)) is fourth
del third
gc.collect()
assert notes[fourth] == "new"
print("after explicit key replacement:", notes[fourth])
del fourth
gc.collect()
assert len(notes) == 0
print("after final owner released:", len(notes))

本次实际输出(以下为结果,不是程序)

equal key value: reviewed
stored key is first: True
second still alive: 7
entries after first released: 0
after explicit key replacement: new
after final owner released: 0

写入的新值仍挂在旧键上

第一行equal key value是reviewed,说明第二次赋值确实生效;下一行stored key is first仍为True,说明被保留的是第一次插入的键对象。仅通过notes[second]成功读取,无法推断second已成为这个位置的生命周期所有者。

删除first并回收之后,second仍能打印编号7,字典长度却变成0。这不是second意外被释放,而是字典中那条弱键引用指向first。first不再存活,对应条目就被移除了;另一个比较相等的对象不会自动接管那条引用。

需要转移身份时先移除旧项

第二段先写入third,再明确删除notes[third],随后用fourth新建条目。此时检查实际键为fourth,删除third不影响new值。最后释放fourth,字典才再次变空。这个顺序使“更新备注”与“改由哪个实例维持条目”成为两个可见动作。

是否应该这么做取决于业务。如果备注本来就属于每个实例,值相等的两个实例却共享键位置,可能说明键设计不合适。若需要各实例独立关联,可以设计采用对象身份相等规则的类型;若希望同一业务编号长期共享备注,通常应由明确持有该编号的普通映射负责,而不是期待弱键字典替业务延长生命周期。

调试观察也可能改变回收结果

本例只短暂检查next(iter(notes))是否为first,没有把键列表保存下来。若为了调试写出saved = list(notes),saved会继续强持有键对象,删除first后条目可能仍在。此时字典并未失效,是观察工具自己增加了持有者。

watch是弱引用,可以在不长期拥有first的情况下检查它是否已消失。但拿到watch()的返回对象并把它保存进普通变量,同样会形成新的强引用。查看生命周期时,要同时检查程序变量、容器、调试器和异常堆栈中是否仍有对象。

弱键只削弱键这一侧

例子把值设计成短字符串,因此值不会反过来引用键。若把含有键对象的列表或绑定到该对象的方法放进值里,容器经值又可能强持有键,预期的自动消失就不会发生。设计关联信息时,应画出完整引用关系,而不只看容器名字里有没有Weak。

程序验收的是CPython中这组受控对象,没有承诺所有实现都在del语句后立即回收,也没有提供过期时间、并发原子更新或容量策略。需要按时间清理、跨请求保留或多线程修改时,应另加相应机制;不要把自动移除条目当成一套完整缓存政策。

参考资料

资料核验日期:2026年10月2日。以上输出来自文中固定输入的本地实跑,程序退出码为0。

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。