首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Python 的 defaultdict 把我坑惨了,原来缺失键会自动创建,但 `__missing__` 的副作用让我调试到崩溃

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

原创
作者头像
风一样的男子
发布2026-08-14 14:44:20
发布2026-08-14 14:44:20
1570
举报
文章被收录于专栏:编程教程编程教程

一个让我怀疑人生的 Bug

先讲一个让我怀疑人生的事儿。

去年做一个数据处理系统,要从日志文件里统计每个用户的访问次数。日志格式很简单——每行一个用户 ID,大概几百万行。

这是最常见的统计场景,Python 里有好几种做法。我当时想用 defaultdict 来写,简洁优雅:

代码语言:javascript
复制
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 这种写法去判断某个用户存不存在,而 defaultdictget 操作下本来不会创建新键,但我不小心用了 counts[user_id] 去读取——就这一下,一个新键就被"自动创建"了,值被设成了默认的 0。

我的字典在不知不觉中被污染了。调试的时候打印 len(counts),每次结果都不一样,因为每次调试的时候我都在"读取"中不断创造新的键。

那天我才真正理解了:defaultdict 的"自动创建",不是只在赋值的时候发生,而是在你访问不存在的键的时候就会发生。 读取和写入,都会触发这个行为。

defaultdict 的工作原理:你以为你懂,其实你不懂

先看一下 defaultdict 最基础的用法:

代码语言:javascript
复制
from collections import defaultdict

d = defaultdict(int)      # int() 返回 0
d = defaultdict(list)     # list() 返回 []
d = defaultdict(set)      # set() 返回 set()
d = defaultdict(lambda: "默认值")  # 也可以自定义

当你访问一个不存在的键时,defaultdict 不会抛出 KeyError,而是调用你传入的那个工厂函数,生成一个默认值,把这个键和默认值塞进字典,然后返回这个默认值。

代码语言:javascript
复制
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__ 方法。它的实现大概长这样:

代码语言:javascript
复制
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] 的方式访问一个不存在的键,不管目的是什么,它都会执行"创建并插入"这个动作。

这就带来了两个致命的问题:

问题一:只读操作也会污染数据

你的本意只是"看一眼这个键在不在",结果字典里多了一个键。

代码语言:javascript
复制
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 会连环爆炸

这个更坑。

假设你要做一个多级统计——按省份统计城市,按城市统计用户。你会自然地想到嵌套字典:

代码语言:javascript
复制
from collections import defaultdict

# 两级嵌套
data = defaultdict(lambda: defaultdict(list))

# 往里面塞数据
data["广东"]["广州"].append("张三")
data["广东"]["深圳"].append("李四")
data["浙江"]["杭州"].append("王五")

一切看起来都很正常。直到你有一天想遍历这个数据结构,打印出所有省份和城市:

代码语言:javascript
复制
for province, cities in data.items():
    print(f"省份: {province}")
    for city, users in cities.items():
        print(f"  城市: {city}, 用户: {users}")

这段代码本身没问题。但如果你在遍历的过程中做了一些"读取"操作——比如你想看看某个城市存不存在,用了 cities["佛山"]——那么"佛山"这个键就会被创建,值为空列表,然后出现在你的遍历结果里。

更可怕的是,因为你是在遍历 cities.items(),而在遍历过程中又往 cities 里插入了新键,这可能会引发 "字典在迭代时改变大小" 的运行时错误。

代码语言:javascript
复制
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 最安全、最经典的使用场景。比如统计计数、分组:

代码语言:javascript
复制
counts = defaultdict(int)
groups = defaultdict(list)

for item in data:
    counts[item] += 1        # 只写,完美
    groups[item].append(x)   # 只写,完美

场景二:读取时用 get 或 in

如果你需要读取数据,用 get 或者先判断 in

代码语言:javascript
复制
# 安全的方式
value = d.get("key")          # 返回 None 或默认值,不创建
if "key" in d:                # 不创建
    print(d["key"])           # 此时再读就是安全的了

# 危险的方式
value = d["key"]              # 如果 key 不存在,它就被创建了

场景三:不确定要不要用,就用普通 dict + setdefault

如果你觉得 defaultdict 的自动创建行为让你心里没底,那就用普通字典加上 setdefault

代码语言:javascript
复制
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 做读取判断。 这个习惯能让你少熬一个下午。

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

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一个让我怀疑人生的 Bug
  • defaultdict 的工作原理:你以为你懂,其实你不懂
  • __missing__ 魔术方法:一切副作用的根源
    • 问题一:只读操作也会污染数据
    • 问题二:嵌套 defaultdict 会连环爆炸
  • 什么时候安全?什么时候危险?
  • 那到底该怎么用?
    • 场景一:只往里面写,不读不存在的键
    • 场景二:读取时用 get 或 in
    • 场景三:不确定要不要用,就用普通 dict + setdefault
    • 场景四:嵌套结构用 __missing__ 感知的封装
  • 写在最后
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档