一段代码把 d.get(k, build()) 当成懒加载用,压测时发现那个「只在键缺失时才该跑」的函数每秒被调用了几万次。get 的第二个参数不是回调,只是一个普通实参,它在函数真正被调用之前就已经算完了。
默认值是在哪一刻被求值的?
Python 的求值顺序是先算实参,再调用函数。d.get(key, build()) 里,build() 属于实参的一部分,必须在 get 开始执行前得到一个确定的值。
所以无论 key 在不在字典里,build() 都已经跑完了。get 拿到的是一个现成的结果,它没有能力、也没有机会去延迟计算。
python
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() 同样先算。
有副作用的时候,后果有多严重?
求值本身贵一点也就罢了,怕的是默认值表达式带着副作用:写缓存、发请求、开文件、记指标。
python
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 的名字容易让人以为它也是「没有才建」,但它的默认值同样是普通实参,照样先求值。它能省掉的只是「再赋值一次」这个动作,不是「构造默认值」这个动作。
python
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 不一样,它接收的是工厂函数本身,不是调用结果。工厂只有在真的缺失、真的需要创建默认值时才被执行。
python
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 有个副作用要提前接受:读一个不存在的键,等于往字典里插入一个。这在计数、分组场景里很方便,但如果只是「探一下有没有」,就会悄悄改变字典的大小和后续遍历结果。
python
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」和「没有这个键」完全是两件事,混在一起就会出现「用户余额显示成默认值」这种诡异现象。
python
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 的返回值同时编码「有没有」和「是什么」。
python
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 返回的是这个函数本身,不是它的执行结果。
python
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 也有同样的问题吗?
有。判断标准不是函数名,而是「默认值是不是普通实参」,只要满足,就一定在调用前求值。
python
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 没问题,可读性也最好。
python
timeout = config.get("timeout", 30)
如果构造有成本或者有副作用,就先判断再构造,把求值时机和判断绑在一起。
python
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 的默认值是一个普通实参,这个事实决定了它不可能懒。凡是把函数调用塞进第二个参数的写法,都要先问一句:这个调用每次都执行,我能接受吗。
写缓存和加载逻辑时,把「判断存在」和「构造默认值」拆成两步,代码会长两行,但行为变得可预期。