Python 字典缺省钩子:方括号能读到默认值,get 为什么却返回空
默认值出现了,键却仍然不存在
给 dict 子类定义 __missing__ 后,读取未知配置项可以返回备用值。可调用方从 d[key] 改成 d.get(key),结果又变成 None;甚至刚刚读出一个默认值,检查 key in d 仍然为假。这些观察并不矛盾,读取方式和是否写入是两个需要分别说明的约定。
下面在 Python 3.12.14 中实现一个很小的字典子类,只记录缺失访问并返回字符串,不连接外部配置源。保存为 demo.py,执行 python demo.py。测试同时覆盖已有值、已有但值为 None、缺失键和显式插入,让每次行为都有对应的状态检查。
先让钩子保持不写入
Fallback 的 __missing__ 把键追加到 misses 列表,再返回带名称的备用字符串。这个方法没有 self[key]=value,所以不会创建条目。调用两次 d["rate"],应该触发两次缺失处理。我们有意不做缓存,便于观察“返回一个值”与“把值保存下来”的区别。
接着分别使用 get 的默认参数、成员测试和 setdefault。它们是继承自 dict 的原有方法,本例没有重写这些入口。每个阶段都检查 misses,避免仅从返回值相同就推断内部经过了同一条调用路径。最后才确认 setdefault 插入后,方括号读到实际存储值。
AI概念示意图:方括号入口可以调用备用值抽屉,另一个读取入口保持独立路径。图片只说明概念,不是运行截图。
class Fallback(dict):
def __init__(self, values):
super().__init__(values)
self.misses = []
def __missing__(self, key):
self.misses.append(key)
return "missing:" + key
d = Fallback({"known": 7, "nullable": None})
assert d["known"] == 7 and d["nullable"] is None
assert d.misses == []
first = d["rate"]
second = d["rate"]
assert (first, second) == ("missing:rate", "missing:rate")
assert d.misses == ["rate", "rate"]
assert "rate" not in d
print("subscript:", first, second, d.misses)
print("key absent:", "rate" not in d)
plain = d.get("rate")
explicit = d.get("rate", 99)
assert plain is None and explicit == 99
assert d.get("nullable", 99) is None
assert d.misses == ["rate", "rate"]
print("get:", plain, explicit)
stored = d.setdefault("rate", 3)
assert stored == 3 and d["rate"] == 3
assert "rate" in d
assert d.misses == ["rate", "rate"]
assert d.setdefault("rate", 8) == 3
print("stored:", d["rate"], "miss calls:", len(d.misses))把输出拆成调用与存储两条线
subscript 输出两次 missing:rate,调用列表中也确实出现两次 rate。随后 key absent 为 True,说明之前两个返回值都没有自动进入字典。若默认值的计算成本很高,这种不缓存的钩子会反复计算;是否缓存应由实现明确决定,而不是由调用方猜测。
get 输出 None 和 99,分别来自 get 自身的省略默认值与显式默认值,缺失调用列表没有增长。__missing__ 是 dict 的方括号读取遇到缺失键时使用的钩子,不是所有字典操作共同经过的总入口。成员测试同样不会为了回答是否存在而生成默认值。
stored 输出三,且字典中开始存在 rate。这个变化来自 setdefault 的插入语义,和钩子没有关系。对 nullable 的读取仍返回已存储的 None,也不会进入缺失处理;字段存在但为空,与键不存在必须分开表达,否则配置排错会把两种状态混在一起。
为接口选择一种清楚的缺省策略
如果缺省值应在首次访问后固定下来,可以在 __missing__ 内明确赋值再返回,但这样一次读取就会改变容器,需要告知使用者。本例没有采用这种策略,因此不能把它当作 defaultdict 的完整替代。标准容器和自定义容器即便都叫“默认字典”,持久化行为也可能不同。
如果你继续重写 get,让它主动调用 self[key],那么 get 的行为当然可以改变;本文结论针对没有重写相关入口的 dict 子类。外部映射类也可能有自己的协议实现,不能仅看方法名字就断定它会沿用 dict 的内部规则。
另外,setdefault(key, expensive()) 会先求出实参,不能靠它实现按需计算。遇到默认值逻辑分散时,先列出允许使用的访问入口,再测试缺失、已存空值和重复访问三个场景。把调用次数与最终键集合一起纳入验收,就能发现返回值表面正常但状态意外变化的问题。


