一个让我怀疑人生的 Bug
先讲一个让我怀疑人生的事儿。
去年做一个数据处理系统,要从日志文件里统计每个用户的访问次数。日志格式很简单——每行一个用户 ID,大概几百万行。
这是最常见的统计场景,Python 里有好几种做法。我当时想用 defaultdict 来写,简洁优雅:
from collections import defaultdict
counts = defaultdict(int)
with open("access.log") as f:
for line in f:
user_id = line.strip()
counts[user_id] += 1
# 找出访问次数最多的用户
top_user = max(counts, key=counts.get)
print(top_user, counts[top_user])
代码跑完了,结果也出来了。一切看起来都很完美。
直到有一天,我需要在统计完之后,遍历这个 defaultdict 去做一些额外的分析——比如找出所有访问次数为 0 的用户。逻辑上这些用户应该是"不存在"的,但打印出来一看,字典里凭空多出来一堆键,值全都是 0。
我明明没有往字典里写过这些键啊!它们是从哪儿来的?
更诡异的是——这些多出来的键,是我在分析代码里 读取 的时候自己生成的。因为我用了 if counts.get(user_id) is None 这种写法去判断某个用户存不存在,而 defaultdict 在 get 操作下本来不会创建新键,但我不小心用了 counts[user_id] 去读取——就这一下,一个新键就被"自动创建"了,值被设成了默认的 0。
我的字典在不知不觉中被污染了。调试的时候打印 len(counts),每次结果都不一样,因为每次调试的时候我都在"读取"中不断创造新的键。
那天我才真正理解了:defaultdict 的"自动创建",不是只在赋值的时候发生,而是在你访问不存在的键的时候就会发生。 读取和写入,都会触发这个行为。
defaultdict 的工作原理:你以为你懂,其实你不懂
先看一下 defaultdict 最基础的用法:
from collections import defaultdict
d = defaultdict(int) # int() 返回 0
d = defaultdict(list) # list() 返回 []
d = defaultdict(set) # set() 返回 set()
d = defaultdict(lambda: "默认值") # 也可以自定义
当你访问一个不存在的键时,defaultdict 不会抛出 KeyError,而是调用你传入的那个工厂函数,生成一个默认值,把这个键和默认值塞进字典,然后返回这个默认值。
d = defaultdict(list)
d["a"].append(1) # "a" 不存在,自动创建为 [],然后 append(1)
print(d) # {"a": [1]}
value = d["b"] # "b" 不存在,自动创建为 []
print(d) # {"a": [1], "b": []} ← 注意,"b" 被创建了
看到了吗?只是读取一下 d["b"],字典就多了一个键。这个行为在普通字典里是不可能发生的——普通字典读取不存在的键会直接报错。
那么问题来了:**defaultdict 是怎么做到这一点的?**
答案在 Python 的一个特殊方法里——__missing__。
__missing__ 魔术方法:一切副作用的根源
Python 的字典类有一个叫做 __missing__ 的钩子方法。当你在字典里查找一个不存在的键时,Python 会先看看这个字典有没有定义 __missing__ 方法。如果有,就调用它;如果没有,就抛出 KeyError。
defaultdict 就是实现了 __missing__ 方法。它的实现大概长这样:
class defaultdict(dict):
def __init__(self, default_factory, **kwargs):
self.default_factory = default_factory
super().__init__(**kwargs)
def __missing__(self, key):
if self.default_factory is None:
raise KeyError(key)
value = self.default_factory() # 调用工厂函数生成默认值
self[key] = value # 把键值对塞进字典
return value # 返回默认值
这个实现看起来挺合理的,对吧?缺失键就创建一个,返回去,你继续用。
但问题在于:__missing__ 被触发的时候,不区分你是在读还是在写。 只要你用 d[key] 的方式访问一个不存在的键,不管目的是什么,它都会执行"创建并插入"这个动作。
这就带来了两个致命的问题:
问题一:只读操作也会污染数据
你的本意只是"看一眼这个键在不在",结果字典里多了一个键。
d = defaultdict(list)
# 我只是想判断一下 "x" 存不存在
if "x" in d: # 注意:用 in 不会触发 __missing__
print("存在")
# 但如果是这样
value = d.get("x") # get 也不会触发 __missing__,这是安全的
# 可如果你不小心这样写
if d["x"]: # 危险!直接触发了 __missing__,创建了 "x"
print("存在")
一个 d["x"] 就让字典里多了一个 "x": [],而你的本意只是想看一眼。
问题二:嵌套 defaultdict 会连环爆炸
这个更坑。
假设你要做一个多级统计——按省份统计城市,按城市统计用户。你会自然地想到嵌套字典:
from collections import defaultdict
# 两级嵌套
data = defaultdict(lambda: defaultdict(list))
# 往里面塞数据
data["广东"]["广州"].append("张三")
data["广东"]["深圳"].append("李四")
data["浙江"]["杭州"].append("王五")
一切看起来都很正常。直到你有一天想遍历这个数据结构,打印出所有省份和城市:
for province, cities in data.items():
print(f"省份: {province}")
for city, users in cities.items():
print(f" 城市: {city}, 用户: {users}")
这段代码本身没问题。但如果你在遍历的过程中做了一些"读取"操作——比如你想看看某个城市存不存在,用了 cities["佛山"]——那么"佛山"这个键就会被创建,值为空列表,然后出现在你的遍历结果里。
更可怕的是,因为你是在遍历 cities.items(),而在遍历过程中又往 cities 里插入了新键,这可能会引发 "字典在迭代时改变大小" 的运行时错误。
for province, cities in data.items():
for city, users in cities.items():
# 下面这行会在遍历过程中创建新键
if len(cities["佛山"]) > 0: # "佛山" 不存在,被创建了
pass
# 当外层循环进入下一个省份时,上面的内层循环已经污染了 data
你在调试的时候会发现——明明只存了 3 个省份的数据,遍历结果里却多出来一堆你从来没写过的省份和城市。而且每次运行,多出来的键还不一样。
这就是我说的"调试到崩溃"——你永远无法确定你的数据里哪些是真实存在的,哪些是被 __missing__ 无中生有创造出来的。
什么时候安全?什么时候危险?
经过那次事故,我把 defaultdict 的行为总结成了一个表格:
| 操作方式 | 键不存在时是否创建 | 是否安全 |
d[key] = value(赋值) |
创建 | 安全,这就是你要的 |
d[key](读取) |
创建 | 危险! |
d.get(key) |
不创建 | 安全 |
key in d |
不创建 | 安全 |
d.setdefault(key, default) |
只有赋值时才创建 | 安全(因为你有意为之) |
for key in d:(遍历) |
不创建 | 安全 |
d.items() / d.values() / d.keys() |
不创建 | 安全 |
核心原则:只有用方括号 d[key] 读取不存在的键时,才会触发 __missing__。
所以如果你已经习惯了普通字典的 if key in dict 或者 dict.get(key),那问题不大。问题是很多人用了 defaultdict 之后,思维还停在普通字典上,觉得 d[key] 只是"读一下",忘了它会自动创建。
那到底该怎么用?
踩完这个坑之后,我对 defaultdict 的态度是:用,但要小心用。
场景一:只往里面写,不读不存在的键
这是 defaultdict 最安全、最经典的使用场景。比如统计计数、分组:
counts = defaultdict(int)
groups = defaultdict(list)
for item in data:
counts[item] += 1 # 只写,完美
groups[item].append(x) # 只写,完美
场景二:读取时用 get 或 in
如果你需要读取数据,用 get 或者先判断 in:
# 安全的方式
value = d.get("key") # 返回 None 或默认值,不创建
if "key" in d: # 不创建
print(d["key"]) # 此时再读就是安全的了
# 危险的方式
value = d["key"] # 如果 key 不存在,它就被创建了
场景三:不确定要不要用,就用普通 dict + setdefault
如果你觉得 defaultdict 的自动创建行为让你心里没底,那就用普通字典加上 setdefault:
d = {}
d.setdefault("a", []).append(1) # 等价于 defaultdict(list) 的写法
或者用 Python 3.9+ 的 dict 并集操作符,或者更经典的 collections.defaultdict 配合 dict.get,都是安全的选择。
场景四:嵌套结构用 __missing__ 感知的封装
如果你需要嵌套结构,又不想被 __missing__ 副作用困扰,可以考虑自己封装一个不自动创建键的"惰性字典",或者用 types 模块里的 MappingProxyType 来生成只读视图——但这些都属于进阶技巧了。
对于大多数人来说,最简单的建议就是:用普通字典 + setdefault 替代 defaultdict,虽然多写几个字,但少掉很多坑。
写在最后
那次"多出来一堆键"的 Bug,我排查了整整一个下午。最后发现问题出在调试代码本身——我在打印字典内容的时候,用的是 for key in d: 循环,循环体里写了一句 if d[key] == []: ...,就这一句 d[key],把所有不存在的键都创建出来了。
你想想——你在调试一个 bug,但你调试的手段本身正在制造新的 bug。那种感觉,就像是追着自己的尾巴转圈,永远找不到出口。
后来我把所有 d[key] 的读取改成了 d.get(key),问题立刻消失。字典干净了,数据准确了,世界安静了。
defaultdict 是一个好工具,但它的好是有前提的——你必须时刻记住,方括号访问不存在的键会创建新键。这个行为在赋值时是便利,在读取时是陷阱。
记住一句话:用 defaultdict 做写入聚合,用 get 和 in 做读取判断。 这个习惯能让你少熬一个下午。
希望你的字典里,永远不会出现你从来没写过的东西。