Python 的 defaultdict 把我坑惨了,原来缺失键会自动创建,但 `__missing__` 的副作用让我调试到崩溃

简介: 本文揭秘一个因`defaultdict`误用引发的“怀疑人生”Bug:仅用`d[key]`读取不存在键,竟自动创建键值对,污染数据。深入剖析`__missing__`机制,厘清安全(`get`/`in`)与危险(方括号读取)操作,并给出四类实用场景建议——用对工具,才能避免调试时“越修越错”。

一个让我怀疑人生的 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。

我明明没有往字典里写过这些键啊!它们是从哪儿来的?

代理 IP 使用小技巧 让你的数据抓取效率翻倍 (65).png

更诡异的是——这些多出来的键,是我在分析代码里 读取 的时候自己生成的。因为我用了 if counts.get(user_id) is None 这种写法去判断某个用户存不存在,而 defaultdictget 操作下本来不会创建新键,但我不小心用了 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 做写入聚合,用 getin 做读取判断。 这个习惯能让你少熬一个下午。

希望你的字典里,永远不会出现你从来没写过的东西。

目录
相关文章
|
22天前
|
Linux 开发者 iOS开发
Qt 软件的开发费用
本简介概述Qt软件开发成本构成:含官方授权费(LGPL免费或商业许可3–5.5万元/人/年)、人力开发费(3万–100万+,占70%–80%)及硬件适配等。合规选型与技术优化可显著降本。
|
供应链 大数据 物联网
案例分析:三一重工集团数字化转型
案例分析:三一重工集团数字化转型
1319 2
|
23天前
|
缓存 监控 IDE
Python 的 is 把我坑惨了,原来 == 和 is 在小整数池外完全是两码事
本文以一次深夜调试经历切入,揭示Python中`==`与`is`的本质区别:`==`比较值是否相等,`is`判断是否为同一对象。通过numpy.int64类型陷阱、小整数池(-5~256)、字符串驻留等实例,说明滥用`is`的隐患,并强调——除判空(`x is None`)等极少数场景外,一律应使用`==`。
52 0
|
Linux 虚拟化 监控
PERF EVENT 硬件篇
简介 本文将通过以 X86 为例子介绍硬件 PMU 如何为 linux kernel perf_event 子系统提供硬件性能采集功能 理解硬件 MSR (Model Specify Register) 可以理解为CPU硬件的专用寄存器,下述的所有寄存器都是这个类型 汇编指令 rdmsr/wrm.
4496 0
|
8月前
|
存储 缓存 弹性计算
8核32G云服务器多少钱?2026年阿里云经济型 e 实例(8 核 32G):价格、性能与适用场景解析
阿里云经济型 e 实例中的 8 核 32G 规格(ecs.e-c1m4.2xlarge),搭配 10M 固定独享带宽、100GB ESSD Entry 云盘,是 “高内存、多核心” 的入门级高配选择。8 核 CPU 能支撑多任务并行处理,32G 大内存可满足中小型数据库、缓存服务等内存密集型场景,10M 独享带宽和 100G 存储则兼顾对外服务与数据存储需求,适合预算有限但需要高配资源的个人开发者、小微企业。下面从价格构成、性能表现、适用场景三方面,用通俗语言拆解这款实例。
|
安全 Java 测试技术
银行转账p图在线生成, 银行转账截图生成器在线制作,怎么用jar实现生成器【装逼必备神器】
这是一个Java银行系统教学项目,包含核心编程技术演示:面向对象设计(如`BankAccount`类封装账户功能)、集合框架(使用`List`和`Map`管理交易记录与账户)
|
9月前
|
机器学习/深度学习 人工智能 自然语言处理
景区博物馆最新应用:历史IP复活,移动AI数字人互动屏打造文旅爆款体验
世优波塔移动AI数字人互动屏,以高精度建模与AI大模型技术“复活”历史人物,打造可对话、会讲解、能导览的沉浸式文旅体验。通过自然语言交互、智能移动导引与多设备联动,让文物“开口说话”,让文化“活”起来,重新定义博物馆、景区与传统文化的连接方式,开启智慧文旅新纪元。
656 0
|
11月前
|
存储 SQL 分布式计算
告别 Hadoop,拥抱 StarRocks!政采云数据平台升级之路
政采云平台作为政府采购数字化的创新典范,集监管、交易、服务于一体,经过近九年的发展,已成为行业内服务范围最广、用户数量最多、交易最活跃、监管产品最丰富的跨区域、跨层级、跨领域的一体化采购云服务平台,日均处理海量高并发数据。Hadoop 作为早期构建大规模数据平台的基石,为政采云平台打开了低成本处理海量非结构化、半结构化数据的可能。然而,伴随业务激增、复杂分析需求及严苛的时效要求,曾经“功臣”的局限性和沉重包袱日益凸显,逐渐成为数据价值释放的“枷锁”。
lol脚本,dnf脚本,挑一挑脚本源码分享
LOL脚本实现技能冷却检测和自动补兵功能,采用多线程运行 DNF脚本包含预设技能序列和智能移动逻辑,支持BOSS血条检测