dict.get 的默认值每次都会求值,哪怕键本来就在

2 阅读8分钟

一段代码把 d.get(k, build()) 当成懒加载用,压测时发现那个「只在键缺失时才该跑」的函数每秒被调用了几万次。get 的第二个参数不是回调,只是一个普通实参,它在函数真正被调用之前就已经算完了。

默认值是在哪一刻被求值的?

Python 的求值顺序是先算实参,再调用函数。d.get(key, build()) 里,build() 属于实参的一部分,必须在 get 开始执行前得到一个确定的值。

所以无论 key 在不在字典里,build() 都已经跑完了。get 拿到的是一个现成的结果,它没有能力、也没有机会去延迟计算。

calls = 0


def build():
    global calls
    calls += 1
    return {"loaded": True}


d = {"user": {"loaded": True}}

for _ in range(3):
    d.get("user", build())

print(calls)  # 3

键自始至终都在,build() 照样被调了三次。这不是 get 的毛病,任何函数调用都是这个规则,print(x, expensive()) 里的 expensive() 同样先算。

有副作用的时候,后果有多严重?

求值本身贵一点也就罢了,怕的是默认值表达式带着副作用:写缓存、发请求、开文件、记指标。

d = {"id": 1}


def touch():
    with open("/tmp/metrics.log", "a") as f:
        f.write("miss\n")
    return 0


for _ in range(5):
    d.get("id", touch())

这段代码本意是「缓存没命中才记一笔」,实际每次循环都写一行日志,监控上的命中率永远是零。拿这份日志当证据去查缓存到底有没有生效,只会越查越糊涂。

默认值是 []、{} 这类可变对象时,问题会换一种形式。每次调用都新建一个,看起来没问题,但一旦有人把结果存起来复用,两个本该独立的默认值就可能指向同一个对象。

更隐蔽的是计数器类副作用。默认值里藏一次 metrics.incr(),键命中时也在累加,指标会随流量线性上涨,而业务数据本身完全正确。这种偏差只能靠对比命中数和请求数的量级来发现。

几种常见写法的求值时机差在哪?

写法键存在时键缺失时说明
d.get(k, build())求值求值默认值是普通实参
d.get(k) or fallback不求值不求值假值也会走 fallback
d[k] if k in d else build()不求值求值存在两次哈希查找
d.setdefault(k, build())求值求值默认值同样先算
defaultdict(build)不调用调用传的是工厂函数本身
d[k] 加捕获 KeyError不触发触发适合缺失很少的场景

setdefault 和 defaultdict 差在哪?

setdefault 的名字容易让人以为它也是「没有才建」,但它的默认值同样是普通实参,照样先求值。它能省掉的只是「再赋值一次」这个动作,不是「构造默认值」这个动作。

calls = 0


def make_list():
    global calls
    calls += 1
    return []


d = {"tags": ["a"]}
d.setdefault("tags", make_list())
print(calls)  # 1

字典里已经有 tags,make_list() 仍然被调用了一次,返回的空列表被直接丢掉。

defaultdict 不一样,它接收的是工厂函数本身,不是调用结果。工厂只有在真的缺失、真的需要创建默认值时才被执行。

from collections import defaultdict

calls = 0


def make_list():
    global calls
    calls += 1
    return []


d = defaultdict(make_list)
d["tags"] = ["a"]
d["tags"].append("b")
print(calls)  # 0

只要键存在,工厂一次都不会被调用。这就是「传函数」和「传函数调用结果」的区别,也是最容易被忽略的一处语法细节。

不过 defaultdict 有个副作用要提前接受:读一个不存在的键,等于往字典里插入一个。这在计数、分组场景里很方便,但如果只是「探一下有没有」,就会悄悄改变字典的大小和后续遍历结果。

from collections import defaultdict

d = defaultdict(int)
print(len(d))   # 0
print(d["hits"])  # 0
print(len(d))   # 1,读一下就多了一个键

setdefault 用在累加场景就没这个问题,因为默认值 []、0 的构造成本几乎为零,先求值也无所谓。惯用写法是 groups.setdefault(key, []).append(item),读起来比连写两次判断干净。

d.get(k) or fallback 会误伤什么?

这个写法很流行,因为它短,而且看起来能兜住 None。问题在于 or 判断的是真假,不是有无。

键存在但值是 0、""、[]、{}、False,都会被当成缺失,于是返回 fallback。业务上「值是 0」和「没有这个键」完全是两件事,混在一起就会出现「用户余额显示成默认值」这种诡异现象。

d = {"count": 0, "name": "", "tags": []}

for k in ("count", "name", "tags"):
    print(k, "->", d.get(k) or "fallback")
# count -> fallback
# name -> fallback
# tags -> fallback

只要字典里可能出现假值,就必须用 in 判断,或者显式比较 is None。

三态并存时怎么区分开?

排查数据缺失类问题时,经常要回答三个不同的问题:键不存在、键存在但值是 None、键存在但值是假值。它们的业务含义分别是「没查过」「查过了但是空」「查过了确实为零」。

d.get(k) 只能回答「值是不是 None」,d.get(k) or x 把后两种情况合并了,k in d 只回答存在性。三种工具各管一段,混着用就会丢信息。

最稳的做法是拿一个唯一的哨兵对象当默认值,让 get 的返回值同时编码「有没有」和「是什么」。

MISSING = object()


def resolve(d, key):
    value = d.get(key, MISSING)
    if value is MISSING:
        return "缺失"
    if value is None:
        return "存在但为 None"
    return f"存在,值为 {value!r}"

哨兵必须是唯一的、不可能出现在业务数据里的对象,模块级 object() 正好满足。AI墨衍 在评审缓存层代码时,看到 or 兜底就会追问一句:这里的 0 和空列表,业务上是真的可以当成没有吗。

写成 lambda 是不是就懒了?

这是另一个高频误解。默认值传一个 lambda,拿到的确实是函数对象,但 get 返回的是这个函数本身,不是它的执行结果。

d = {"a": 1}
value = d.get("a", lambda: 42)
print(value)  # 1
print(d.get("b", lambda: 42))  # <function <lambda> ...>

键缺失时,返回值是一个函数,后面所有对它的算术、序列化都会出错。有人为了补救再加上一对括号,写成 d.get(k, lambda: build())(),这样虽然能拿到结果,但键存在时又变成了对键值调用。

正确的懒加载只有一条路:把构造逻辑挪出参数位置,交给 if、try 或者 defaultdict 决定。

pop 和 getattr 也有同样的问题吗?

有。判断标准不是函数名,而是「默认值是不是普通实参」,只要满足,就一定在调用前求值。

def build():
    print("build 被调用")
    return 0


d = {"a": 1}
d.pop("a", build())  # build 被调用,但返回值被丢弃

getattr(obj, name, build()) 同理:属性存在时,build() 照样先跑一遍。链式的 os.environ.get("A", os.environ.get("B", "unknown")) 也是极端的例子,内层那一次无论如何都会执行。

链式 get 取值会撞上什么坑?

取多层结构时流行写 d.get("user", {}).get("name")。默认值 {} 每次新建,成本很低,真正的坑在下一跳。

如果 user 这个键存在、但值是 None,第一次 get 会把 None 原样返回,第二次 get 就变成了 None.get(...),直接抛 AttributeError。

默认值只管「键不存在」,管不到「键存在但值是 None」。把两者混为一谈,报错就会绕开 try 里的兜底,跑到一个更难定位的地方。

默认值每次都会被求值,那到底该用哪种写法?

如果默认值的构造很便宜,直接用 get 没问题,可读性也最好。

timeout = config.get("timeout", 30)

如果构造有成本或者有副作用,就先判断再构造,把求值时机和判断绑在一起。

if "report" not in cache:
    cache["report"] = build_report()
report = cache["report"]

这段代码对 cache 做了两次哈希查找,但只在真的缺失时才执行 build_report()。缺失很少时换成 try/except KeyError 更快,缺失频繁时反而更慢,因为异常对象的创建和栈展开本身有开销。

需要「一次查找加延迟构造」时,defaultdict 是最贴题的工具,前提是能接受「读一下就建出一个」这个副作用。

多出来的那些调用,测试为什么发现不了?

因为单元测试通常只看返回值对不对。键存在时,get 依然返回正确的那份数据,多余的那次构造被丢弃,断言完全是绿的。

要让它暴露,只能去数调用次数、看 IO 记录,或者给构造函数加一个断言。用 unittest.mock 的 assert_called_once 断言构造函数只被调用一次,这类问题基本藏不住。

并发场景下会连带出什么问题?

多个线程同时读同一个缺失的键,各自执行一遍默认值表达式,最后只有一个结果被写进字典。如果默认值里带着写库或者发请求,重复执行的代价会成倍放大。

get 本身不做任何同步,它只是查一次字典。要在并发下保证只构造一次,得靠锁、functools.lru_cache 这类只算一次的机制,或者干脆把构造挪到启动阶段预热。

构造逻辑能被缓存时,用 lru_cache 包住构造函数的收益最直接:get 照样每次调用它,但内部命中缓存后不会再重复计算。

收尾

get 的默认值是一个普通实参,这个事实决定了它不可能懒。凡是把函数调用塞进第二个参数的写法,都要先问一句:这个调用每次都执行,我能接受吗。

写缓存和加载逻辑时,把「判断存在」和「构造默认值」拆成两步,代码会长两行,但行为变得可预期。