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

1. 一个本该优雅的数据统计,统计出了满屏"幽灵"

上个月,我在做一个用户行为分析的需求。日志里有海量的用户点击数据,我要按"日期+用户ID"做聚合统计。

需求不复杂,但有一个痛点:键可能不存在 。如果用普通的 dict,每次都要先 if key not in dict 再赋值,代码写得像老太太裹脚布,又臭又长。

我心想:"这种场景不就是 defaultdict 的官方使用案例吗?"

于是优雅地写下了这段代码:

ini 复制代码
from collections import defaultdict

def analyze_user_clicks(logs):
    stats = defaultdict(list)  # 每个用户默认一个空列表
    for log in logs:
        date = log['date']
        user_id = log['user_id']
        click_type = log['click_type']
        stats[(date, user_id)].append(click_type)
    
    # 统计每个用户每天的点击次数
    result = {}
    for key, click_list in stats.items():
        result[key] = len(click_list)
    return result

逻辑丝滑,代码清爽。我美滋滋地部署上线。

数据量小的时候一切正常。第三周,数据量涨上来了,我写了个调试脚本,想把 stats 字典里的一部分数据打印出来看看分布情况。

结果一打印,我整个人都不好了。

scss 复制代码
for key in list(stats.keys())[:100]:
    print(key, stats[key])

打印出来的内容里,有一大堆 空列表,对应的键看起来像是"我不小心读过的键"。

我明明只往里面写了数据,什么时候读了那么多不存在的键?再仔细一查,这些"幽灵键"对应的日期和用户ID,在原始日志里根本不存在。

更诡异的是,当我用 if key in stats 去判断某个键是否存在时,判断完之后这个键居然真的出现在字典里了,而且对应的值是一个空列表。

我盯着屏幕沉默了五分钟。这字典居然会"凭空造物"?我以为我写的是统计程序,结果它悄悄给自己"注水"了?


2. 一张让你怀疑人生的截图

先别急,我给你看一个极其简单的 demo,你就明白"凭空造物"是怎么回事了。

python 复制代码
from collections import defaultdict

d = defaultdict(list)

print(f"刚开始,字典长度: {len(d)}")  # 0

# 我"读"了一个不存在的键
value = d['hello']

print(f"读完这个键之后,字典长度: {len(d)}")  # 1
print(f"字典里的键: {list(d.keys())}")  # ['hello']
print(f"这个键对应的值: {value}")  # []

输出:

makefile 复制代码
刚开始,字典长度: 0
读完这个键之后,字典长度: 1
字典里的键: ['hello']
这个键对应的值: []

看懂了吗?我就只是用 d['hello'] 读了一下,什么都没写,字典里就多了一个 'hello': []。这就好比你打开冰箱看了一眼,冰箱里就自动多了一颗白菜。你明明只是想确认有没有白菜,结果它自己创造了一颗。

这要是在现实世界里,相当于你查了一下银行余额,银行就自动给你开了一个新账户并存了 0 块钱进去------虽然没少钱,但账户列表莫名其妙多了一行。

你可能会说:"这有什么大不了的,不就是多了一个空列表吗?"

但当你做大规模数据分析的时候,这种"幽灵键"会疯狂繁殖。你每次调试打印、每次做存在性检查,都有可能创造出一堆你从来没写入过的键。数据量一大,内存暴涨,运行效率直线下降,而且你还以为是业务数据变多了,其实是自己的调试代码在"造物"。


3. 扒开 defaultdict 的底裤:__missing__ 的秘密

为什么普通字典不会这么"自作多情",而 defaultdict 会?

答案藏在一个叫做 __missing__ 的魔法方法里。

普通的 dict 对象,当你用 d['key'] 去读取一个不存在的键时,Python 会直接抛出 KeyError。它老老实实,不存在就是不存在。

defaultdict 重写了 __getitem__ 方法。当它发现你读取的键不存在时,它不会抛异常,而是会调用一个叫 __missing__ 的钩子方法。

__missing__ 的逻辑非常简单粗暴:

ruby 复制代码
# defaultdict 的 __missing__ 大致长这样(简化版)
def __missing__(self, key):
    # 调用你给的 default_factory,创建一个默认值
    default_value = self.default_factory()
    # 把这个键和默认值真的插入到字典里
    self[key] = default_value
    # 返回这个默认值
    return default_value

看到没有?它不只是"返回一个默认值",它是"创建了一个默认值,并把键值对永久写入了字典"。

这就是"读取即写入"的根本原因。defaultdict 的设计哲学是:既然你要读这个键,说明你马上就要用它,那我就帮你"准备好"。这种设计在"你确定要用这个键"的场景下很高效,但如果你只是"好奇看一眼",那就悲剧了。

这就把 __getitem__(读取)和 __setitem__(写入)的界限给模糊了。你明明只写了读取操作,实际执行的是"读取 + 写入"两步。


4. 三个"看不见的子弹",颗颗打中你的生产环境

这种"自动创建"的副作用,会在你不注意的地方埋下三颗雷。

第一颗雷:存在性检查变成了"创造"操作

你可能会写这样的代码来判断某个键有没有数据:

bash 复制代码
if 'some_user' in stats:  # 这里其实已经触发了创建!
    process(stats['some_user'])

in 操作符也会调用 __contains__,但 defaultdict__contains__ 没有被重写 ,它走的是 dict 原生的 __contains__,不会触发 __missing__。所以 'some_user' in stats 本身是安全的。

但如果你用 stats['some_user'] 来做判断,那就中招了:

python 复制代码
if stats.get('some_user'):  # get 是安全的,不会触发
    pass

if stats['some_user']:  # 危险!这会触发创建!
    pass

关键是,很多人用惯了 defaultdict 之后,会不自觉地直接用 d[key] 去读,完全忘记"读这个操作本身就在改字典"。

第二颗雷:调试的时候疯狂造数据

你要打印字典内容,写了个循环:

python 复制代码
for key in keys_to_check:
    print(f"{key}: {stats[key]}")  # 每个不存在的 key,都会变成一个空列表!

你只是想看看这几个键对应的值,结果每次执行调试代码,字典都膨胀一次。你以为数据量在涨,其实是你的调试脚本在"放水"。

最讽刺的是,你排查了半天"为什么数据这么多",结果元凶就是你的排查动作本身。这简直是自指悖论。

第三颗雷:序列化时多出一堆"垃圾"

你辛苦统计完数据,准备存进 JSON 文件:

ini 复制代码
import json
stats = defaultdict(list)
# ... 填充数据 ...
# 中间你可能用 d['some_key'] 读取过一些不存在的键
json.dump(dict(stats), file)  # 转成普通 dict 再存

打开 JSON 文件一看,满屏的空数组:

json 复制代码
{
  "user_123_2024-01-01": ["click_a", "click_b"],
  "user_456_2024-01-01": [],
  "user_789_2024-01-02": [],
  ...
}

明明业务数据只有 100 条,结果存了 10000 条,其中 9900 条是空数组。存文件占空间不说,下游系统读到这些空数据还以为是真实的活跃用户呢。


5. 更隐蔽的连环坑:default_factory 本身也会翻车

default_factory 是你传给 defaultdict 的"造物函数"。它决定了每次创建默认值时调用什么。

最常见的用法是:

scss 复制代码
defaultdict(list)   # 默认空列表
defaultdict(int)    # 默认 0
defaultdict(set)    # 默认空集合

但如果你传了一个有副作用的工厂函数,那就有的玩了。

案例一:工厂函数本身会抛异常

python 复制代码
def expensive_connection():
    print("正在连接数据库...")
    return get_db_connection()  # 万一连不上呢?

d = defaultdict(expensive_connection)

# 某个键不存在,读取时触发 __missing__
try:
    conn = d['new_user']
except Exception as e:
    print(f"连接失败: {e}")

print(f"字典里还有这个键吗?{'new_user' in d}")  # True
# 但这个键的值是啥?是 None?还是根本没设置成功?

__missing__ 在调用 default_factory 之前会先 self[key] = default_value 吗?不会,它会先调用工厂函数,拿到返回值之后再插入。但如果工厂函数抛异常了,插入操作就没执行。但问题是,__getitem__ 已经进入"处理缺失键"的流程了,有些内部状态已经被标记了,你再访问这个键的时候会得到什么?

答案是:这个键可能处于"半插入"状态,调试起来极其恶心。

案例二:default_factory 有"记忆"

python 复制代码
counter = 0
def increment():
    global counter
    counter += 1
    return counter

d = defaultdict(increment)
print(d['a'])  # 1
print(d['b'])  # 2
print(d['a'])  # 1(因为键已经存在了,不会重新调用工厂)

print(d['c'])  # 3

这个例子看起来还能理解。但如果你的工厂函数读写了一个外部状态,而这个外部状态在你"仅仅读取某个键"的时候就被改变了,那造成的 bug 会让你抓破头。


6. 嵌套 defaultdict 的"无限套娃"

还有一种极其流行的写法,是嵌套的 defaultdict

python 复制代码
from collections import defaultdict

# 三层嵌套:用户 -> 日期 -> 点击类型列表
data = defaultdict(lambda: defaultdict(lambda: defaultdict(list)))

这种代码在 LeetCode 题解里经常出现,看起来"一行搞定",但调试起来是噩梦。

你只是想给 data['user1']['2024-01-01'] 追加一个点击事件,结果中间某个键拼错了,比如写成了 data['user1']['2024-01-02']------本来想写 '2024-01-01'------Python 不会报错,而是默默地给你创建了一个全新的"用户->日期"分支。

然后你的统计结果里莫名其妙多了一个"幽灵用户"或"幽灵日期",你根本不知道是哪行代码造出来的。因为没有报错,没有日志,只有数据量的悄悄膨胀。

而且,最顶层那个 defaultdict 对象被转换成普通 dict 传给 JSON 的时候,那些"不小心造出来"的嵌套字典会一起被序列化,你最后存下来的数据膨胀了十倍不止。


7. 怎么从坑里爬出来?

defaultdict 不是不能用,它只是"过于主动"了。如果你接受它的主动,并且清楚它的副作用,它依然是高效的工具。

但在绝大多数业务场景下,尤其是数据统计和调试频繁的场景,建议用更"保守"的方式。

方案一:用普通 dict + setdefault

ini 复制代码
stats = {}
for log in logs:
    key = (log['date'], log['user_id'])
    stats.setdefault(key, []).append(log['click_type'])

setdefault 只在键不存在时才设置默认值,不会在你"读取"的时候偷偷插入。但它每次都要创建一个新的空列表(即使键已经存在了),有轻微的性能开销。

方案二:用普通 dict + defaultdict 只做"局部包装"

如果你非用 defaultdict 不可,那就只在局部函数内部用 ,不要让 defaultdict 对象暴露给外部。

bash 复制代码
def build_stats(logs):
    stats = defaultdict(list)
    for log in logs:
        stats[(log['date'], log['user_id'])].append(log['click_type'])
    # 返回之前转成普通 dict,切断"自动创建"的能力
    return dict(stats)

stats = build_stats(logs)
# 现在 stats 是普通 dict,读不存在的键会报 KeyError,安全了

这样既享受了 defaultdict 在构建阶段的便利,又把它的副作用关在了笼子里。

方案三:用 get 方法,不要用 d[key] 读取

如果你必须保留 defaultdict 对象,那就养成用 d.get(key, default) 的习惯,而不是 d[key]

ini 复制代码
# 危险
value = stats[key]

# 安全
value = stats.get(key, [])

get 不会触发 __missing__,它是字典原生方法,老老实实返回键的值或者默认值,不会在字典里插入任何东西。

方案四:重写 __missing__,让它只返回不插入

如果你想要一种"只读不写"的默认字典,可以自己写一个:

ruby 复制代码
class SafeDefaultDict(dict):
    def __init__(self, default_factory):
        self.default_factory = default_factory
    
    def __missing__(self, key):
        # 只返回默认值,但不插入字典
        return self.default_factory()

这样 d['hello'] 会返回 [],但字典本身不会有 'hello' 这个键。不过要注意,这会破坏 defaultdict 的"写时创建"语义,你可能需要同时搭配 __setitem__ 来实现"赋值时才真正创建"的逻辑。


8. 一张表看懂什么时候会"造物"

我把常见的操作整理成了一张表,方便你快速判断哪些操作是安全的:

操作 是否触发 __missing__ 是否会创建键
d[key] = value ✅(显式赋值)
d[key](读取) 危险!
d.get(key) ❌ 安全
key in d ❌ 安全
d.setdefault(key, []) ❌(键存在时) ✅(键不存在时) ✅(仅键不存在时)
for key in d ❌ 安全
d.keys() / d.values() ❌ 安全
d.pop(key) ❌ 删除键,不创建

记住最核心的一条:只有 d[key] 这种"中括号读取"操作才会触发 __missing__ 并创建键。 其他大多数操作都是安全的。


9. 最后的忠告:缺省值很好,但它不是"智能"的

defaultdict 的设计初衷,是让你在"确定要往这个键写入数据"的场景下少写几行 if 判断。它假设你读一个键的时候,接下来大概率会用到它。

但这个假设在调试、检查、存在性验证等场景下完全不成立。你用 d[key] 去读一个键,Python 就真的给你造了一个键------它不管你只是想看看,它只管"按指令办事"。

所以我对 defaultdict 的态度是:

构建阶段可以用,交付之后最好转成普通 dict

构建阶段用 defaultdict,代码简洁、效率高。但一旦数据构建完成,立刻 return dict(stats) 或者 stats = dict(stats),把"自动创建"的开关关掉。这样后续的读取、调试、序列化全都是普通字典的行为,安全、可控、可预测。

最后送你一句"保命口诀":

中括号读键要当心,缺省字典会造物。构建完就转普通,调试再也不迷糊。

下次你再看到 defaultdict 的时候,先问自己三个问题:

  1. 这个字典的生命周期里,会不会有"只读不写"的操作?
  2. 我会不会在调试的时候打印它?
  3. 我会不会把它序列化成 JSON 存起来?

任何一个问题的答案是"是",就把它转成普通 dict 再继续。你省下的调试时间,够你喝好几杯咖啡了。

相关推荐
大勇前进41 分钟前
千万级大表 SQL Server 查询慢?一套可落地的性能调优实战路径
后端
前端开发张小七41 分钟前
Java 学习笔记 · 第三课:多线程与并发编程(线程、同步、死锁、Lock、乐观锁与悲观锁)
java·后端·程序员
用户名不能为空被占用44 分钟前
记一次数据权限改造,看 NestJS 装饰器与守卫的组合应用
后端·nestjs
摇滚侠1 小时前
SpringBoot 官网 阅读笔记 启用生产就绪功能 端点
spring boot·笔记·后端
我命由我123452 小时前
匈牙利命名法
java·服务器·后端·学习·java-ee·kotlin·学习方法
苏三说技术2 小时前
一线大厂的Git规范
后端
神奇小汤圆2 小时前
阿里面试官问我:“Redis 的 String 底层是怎么设计的?”,我画完 SDS,他点了点头……
后端
Cicada1283 小时前
ccvt:一个用 Rust 写的中国地图坐标系互转命令行工具
开发语言·后端·rust
明月_清风3 小时前
🤗 Hugging Face 模型上传完全指南:从本地到 Hub 的 4 种姿势
前端·后端·ai编程