查看: 231|回复: 0

Python字典默认值KeyError与defaultdict避坑

[复制链接]
发表于 2 小时前 | 显示全部楼层 |阅读模式
在 Python 里,字典遇到缺失键默认会抛出 KeyError。原文围绕一个会崩的 greeting 函数,梳理了七条取默认值的路线:if key in d、try/except KeyError、dict.get、dict.setdefault、collections.defaultdict、__missing__ 子类和 collections.Counter。以下行为与性能数据均来自原文真机实测:CPython 3.11.9 (main, Apr 15 2024, 17:28:11)、Clang 17.0.6、macOS-26.5.2-arm64-arm-64bit;每次 benchmark 做 200,000 次操作,重复 3 次取最优。下面按代码实现、参数语义、常见坑和性能数据重新整理。
  1. name_for_userid = {
  2.     382: 'Alice',
  3.     590: 'Bob',
  4.     951: 'Dilbert',
  5. }
  6. def greeting(userid):
  7.     return 'Hi %s!' % name_for_userid[userid]
复制代码

greeting(382) 返回 'Hi Alice!';greeting(3333) 抛 KeyError: 3333。实测异常类型是 KeyError,repr(e) 是 KeyError(3333),str(e) 是 '3333',也就是 KeyError.__str__ 返回裸值字符串。需求很明确:键缺失时拿到合理默认值,而不是让程序崩溃。

路线一:if key in d 判断,也就是 LBYL。写法先查再取:
  1. def greeting_if_in(userid):
  2.     if userid in name_for_userid:
  3.         name = name_for_userid[userid]
  4.     else:
  5.         name = 'there'
  6.     return 'Hi %s!' % name
复制代码

实测 greeting_if_in(382) 返回 'Hi Alice!',greeting_if_in(3333) 返回 'Hi there!'。它零依赖、不会修改字典,调用后 'zzz' in d 仍为 False;缺点是命中时要两次哈希查找,一次 in、一次下标取值,代码也更长。适合判断本身有额外意义的场景,比如不存在就报错、记日志或走另一条分支。

路线二:try/except KeyError,即 EAFP。
  1. def greeting_try(userid):
  2.     try:
  3.         return 'Hi %s!' % name_for_userid[userid]
  4.     except KeyError:
  5.         return 'Hi there!'
复制代码

实测 greeting_try(382) 返回 'Hi Alice!',greeting_try(3333) 返回 'Hi there!'。这条路线命中时最快,实测 14.0 ns/次,性能全场第一;但缺失时最慢,抛异常要构造 traceback,实测 90.9 ns/次,约是 if in 的 5.4 倍。它强依赖数据分布:键缺失罕见、命中是常态的热点代码才适合。

路线三:dict.get(key, default)。大多数场景的默认答案:
  1. def greeting_get(userid):
  2.     return 'Hi %s!' % name_for_userid.get(userid, 'there')
复制代码

dict.get 有三个关键性质:只读,命中与否都不修改字典;一次查找,命中时实测 19.4 ns/次;默认值可省略,d.get(k) 等价于 d.get(k, None)。

坑一:default 参数是普通表达式,无论键是否存在都会求值。实测把一个带副作用的函数当默认值,连续调用 5 次 d.get(404, expensive_default()):
  1. calls = 0
  2. def expensive_default():
  3.     global calls
  4.     calls += 1
  5.     return 'GUEST'
  6. d = {}
  7. for _ in range(5):
  8.     d.get(404, expensive_default())
  9. print(calls)  # 实测 5
  10. print(d)      # 实测 {}
复制代码

函数被调用 5 次,但字典 d 始终为空,因为 get 不会写入。setdefault 也有同样问题:即使键一直存在、默认值根本没用上,默认值函数仍会被调用。量化后果是默认值越重浪费越大:20 万次循环中,{}.get(k, 0) 总耗时 0.0102 s,{}.get(k, []) 总耗时 0.0378 s,约 3.7 倍。列表字面量每次都要真的创建一个新对象。不要往 get 或 setdefault 的默认值位置放大容器、函数调用、属性访问、datetime.now() 等构造代价大的表达式。要惰性构造,用 defaultdict 或 __missing__。

坑二:None 有歧义。d.get(k) 返回 None 时,无法区分键不存在和键存在但值为 None。实测 d = {'a': None, 'b': 1} 时,d.get('a') 与 d.get('zzz') 都返回 None,二者比较为 True;而 'a' in d 为 True,'zzz' in d 为 False。如果值为 None 是合法业务状态,必须额外用 in 判断,或换哨兵对象:
  1. _MISSING = object()
  2. value = d.get(k, _MISSING)
  3. if value is _MISSING:
  4.     pass  # 键确实不存在
复制代码

路线四:dict.setdefault(key, default)。语义比 get 多一步:键不存在时把默认值写进字典并返回;键已存在则直接返回已有值,不覆盖。
  1. d = {'a': 1}
  2. r1 = d.setdefault('a', 99)  # 实测 1,不覆盖
  3. r2 = d.setdefault('b', 99)  # 实测 99,插入并返回
  4. print(d)                    # 实测 {'a': 1, 'b': 99}
复制代码

键存在但值是 None 时也不会被覆盖,因为 setdefault 判断的是键是否在字典里,而不是值是否为假:
  1. d = {'a': None}
  2. d.setdefault('a', 5)  # 返回 None,字典仍为 {'a': None}
复制代码

典型用法是分组:
  1. words = ['apple', 'banana', 'avocado', 'blueberry', 'cherry']
  2. groups = {}
  3. for w in words:
  4.     groups.setdefault(w[0], []).append(w)
  5. print(groups)
  6. # 实测 {'a': ['apple', 'avocado'], 'b': ['banana', 'blueberry'], 'c': ['cherry']}
复制代码

setdefault 返回的是字典里那个列表对象的引用,不是副本,所以 append 改的是字典里的真身。

头号坑是 get+append 会静默丢数据:
  1. d7 = {}
  2. d7.get('x', []).append(1)
  3. d7.get('x', []).append(2)
  4. print(d7)  # 实测 {}
复制代码

原因:get 每次返回新建的临时列表,append 改完后没有引用,直接被垃圾回收,字典自始至终没被写过。要取不到就创建并写回,用 setdefault 或 defaultdict,绝不能用 get。次要坑是默认值每次构造:对已存在的键连续调用 1000 次 setdefault(1, TrackedList()),TrackedList.__init__ 会被调用 1000 次,构造出 1000 个随后丢弃的列表。

路线五:collections.defaultdict。它用工厂函数取代固定默认值,把缺失时怎么办下沉到容器里。
  1. from collections import defaultdict
  2. d = defaultdict(list)
复制代码

当通过 d[key] 访问缺失键时,defaultdict 调用 default_factory() 生成默认值、写入字典、然后返回。defaultdict(list) 适合分组:
  1. groups2 = defaultdict(list)
  2. for w in words:
  3.     groups2[w[0]].append(w)
  4. # 实测 {'a': ['apple', 'avocado'], 'b': ['banana', 'blueberry'], 'c': ['cherry']}
复制代码

比 setdefault 少一次方法调用。实测不同键拿到的列表互相独立:d['p'] is not d['q'] 为 True,内容各自独立。defaultdict(int) 适合计数:
  1. counter = defaultdict(int)
  2. for ch in 'abracadabra':
  3.     counter[ch] += 1
  4. # 实测 {'a': 5, 'b': 2, 'c': 1, 'd': 1, 'r': 2}
  5. # 实测 sum(counter.values()) -> 11
复制代码

int() 返回 0,所以 counter[ch] += 1 在键缺失时能从 0 开始累加。常见工厂差异:defaultdict(list) 默认 [],用于分组、邻接表、一对多索引,每次缺失键都会新建空列表;defaultdict(int) 默认 0,用于计数、累加、词频,布尔语境下 0 为假需注意;defaultdict(lambda: 默认值) 可返回任意默认值,但无法 pickle。

核心坑:defaultdict 的读取会写入字典。
  1. dd = defaultdict(list)
  2. print(len(dd))  # 实测 0
  3. dd['missing_key']  # 只是看一眼
  4. print(len(dd))  # 实测 1
  5. print(dict(dd)) # 实测 {'missing_key': []}
复制代码

哪些操作会触发工厂?实测 defaultdict(int) 下:d[k] 会调用工厂并写入字典,缺失键读取后 len 从 0 变 1;d.get(k) 不会,返回 None 且 len 保持 0;k in d 不会,返回 False 且 len 保持 0;d.setdefault(k, v) 不会走工厂,使用传入的 v。也就是说只有下标访问 d[k] 才会触发工厂。需要探测但不污染时,用 in 或 get。

defaultdict(None) 等价于普通 dict,缺失键依旧抛 KeyError:
  1. nd = defaultdict(None)
  2. nd['x']  # 实测 KeyError: 'x'
复制代码

边界:default_factory 必须是可调用对象或 None,内置类型 list、int、set、str、命名函数、类、lambda 都可以。常见错误是写成 defaultdict(0) 或 defaultdict([]),会得到 TypeError: first argument must be callable or None;正确写法是 defaultdict(int)、defaultdict(list)。

重要坑:lambda 工厂无法被 pickle。实测把 defaultdict(lambda: 0) 做 pickle.dumps 会得到 AttributeError: Can't pickle local object '<lambda>.<locals>.<lambda>'。对照结果:int、list、模块级命名函数、自定义类都可成功 round-trip;lambda 失败。原因是 pickle 保存函数或类时按名字引用,而 lambda 是匿名对象,没有可稳定引用的名字。只要 defaultdict 需要跨进程传输、写缓存文件、丢给多进程,就不要用 lambda 工厂,换成命名函数或自定义类:
  1. def make_default():
  2.     return {'n': 0}
  3. d = defaultdict(make_default)  # 实测可 pickle
复制代码

拷贝与转换:copy.copy(d) 保留 default_factory,实测 True;copy.deepcopy(d) 也保留,且内部元素独立深拷贝;pickle 往返保留可 pickle 的工厂;dict(d) 会丢失工厂,返回普通 dict。几乎所有要交给外部的场合,比如 json.dumps、日志打印、返回给调用方,都推荐显式 dict(d) 转回普通 dict,避免调用方拿到一个还会自动长键的容器。

路线六:__missing__ 自定义子类。dict.__getitem__ 找不到键时,如果子类定义了 __missing__,就会调用它,而不是直接抛 KeyError。关键性质:__missing__ 只在 d[key] 时触发。
  1. class DefaultingDict(dict):
  2.     def __missing__(self, key):
  3.         return f'<没有 {key} 这个键>'
  4. md = DefaultingDict({'a': 1})
  5. before = dict(md)
  6. print(md['zzz'])        # 实测 <没有 zzz 这个键>
  7. print(dict(md) == before)  # 实测 True,字典没有被污染
  8. print('zzz' in md)      # 实测 False
  9. print(md.get('zzz', 'GET_FALLBACK'))  # 实测 GET_FALLBACK
复制代码

get 和 setdefault 不会触发 __missing__。setdefault 只把它自己传的默认值写进去;__missing__ 是 [] 专用通道。三种实用实现:A. 只读默认,不写回字典,适合探测式访问;B. 自动建容器,等价 defaultdict(list),但行为可见可控:
  1. class AutoListDict(dict):
  2.     def __missing__(self, key):
  3.         value = []
  4.         self[key] = value
  5.         return value
  6. ad = AutoListDict()
  7. ad['x'].append(1)  # 实测 {'x': [1]}
复制代码

它比 defaultdict(lambda: []) 更好的一点是可 pickle,因为它是模块级类,以后加日志、统计也方便。C. 大小写不敏感字典:
  1. class CaseInsensitiveDict(dict):
  2.     def __missing__(self, key):
  3.         if isinstance(key, str):
  4.             return self[key.upper()]
  5.         raise KeyError(key)
  6.     def __setitem__(self, key, value):
  7.         if isinstance(key, str):
  8.             key = key.upper()
  9.         super().__setitem__(key, value)
  10. ci = CaseInsensitiveDict()
  11. ci['Name'] = 'Alice'
  12. # 实测 ci['name'] -> 'Alice'
  13. # 实测 ci['NAME'] -> 'Alice'
  14. # 实测 ci.keys() -> ['NAME']
复制代码

defaultdict 与 __missing__ 怎么选:defaultdict 默认值来源单一工厂,所有键相同,固定写入,代码一行,可读性高;__missing__ 可以按键逐个计算,是否写入由你决定,需要写一个类,可加日志、统计、类型校验、缓存。一句话:所有缺失键用同一种默认值,用 defaultdict;缺失时的行为需要定制,用 __missing__。

路线七:collections.Counter。Counter 是 dict 的子类,实测 isinstance(Counter(), dict) 为 True,专为计数而生。它与 defaultdict(int) 的关键差异是:读缺失键不写入。
  1. from collections import Counter
  2. c = Counter('abracadabra')
  3. print(len(c))            # 实测 5
  4. v = c['not_present']     # 实测 0
  5. print(len(c))            # 实测 5,没有变
  6. c.get('also_absent')     # 实测 None,注意不是 0
  7. 'yet_another_absent' in c  # 实测 False
  8. print(len(c))            # 实测 5
复制代码

Counter 的 __missing__ 直接返回 0 而不写入,所以不会被探查式读取污染。对照 defaultdict(int):d['missing'] 会让 len 从 0 变 1。注意 Counter 的 c[key] 返回 0,但 c.get(key) 返回 None,因为它继承的是 dict.get,没有覆盖。常用方法实测:c.most_common(3) 返回 [('a', 5), ('b', 2), ('r', 2)];c.most_common() 按计数降序返回全部;c.total() 在 Python 3.10+ 返回 11,等价 sum(c.values());sorted(c.elements()) 返回 ['a','a','a','a','a','b','b','c','d','r','r']。计数器加减法实测:Counter('abc') + Counter('abd') 返回 {'a': 2, 'b': 2, 'c': 1, 'd': 1};Counter('abd') - Counter('abc') 返回 {'d': 1},只保留正数,负数被丢弃;Counter('abracadabra') - Counter('abracadabra') 返回 {}。构造与更新实测:Counter(['x', 'x', 'y']) 返回 {'x': 2, 'y': 1};Counter('abracadabra').update(['a']) 原地累加。

何时用 Counter 而不是 defaultdict(int):单纯计数、最多再看 max(),两者皆可,Counter 更省心;要看前 N 名,用 Counter.most_common(n);要做计数器加减、集合运算,用 Counter;要按计数 sorted() 或迭代,用 Counter;想在缺失键读取时污染字典,比如为了后续 d.keys() 完整,用 defaultdict(int);想要 .get() 返回 0 而不是 None,两者都不行,Counter.get 是 dict.get,需要自己封装。

性能实测全景。命中键存在时:try/except KeyError 14.0 ns/次,200,000 次总耗时 0.0028 s;dict.get 19.4 ns/次,0.0039 s;dict.setdefault 21.4 ns/次,0.0043 s;defaultdict 索引 22.1 ns/次,0.0044 s;if in + 索引 23.0 ns/次,0.0046 s;条件表达式 d[k] if k in d else v 25.3 ns/次,0.0051 s。

缺失键不存在时,原文给出的已知数据:if in 判断,未命中不索引,16.9 ns/次,200,000 次总耗时 0.0034 s;dict.get 21.4 ns/次,0.0043 s;条件表达式 25.0 ns/次。try/except 缺失时最慢,实测 90.9 ns/次,约是 if in 的 5.4 倍。原文缺失场景表格后续数据未完整给出,这里不补造。

核心选型结论:只读默认不写字典,用 dict.get;需要区分 None 与缺失,用 in 或哨兵对象;缺失要写回默认值,用 setdefault 或 defaultdict,但 setdefault 的默认表达式每次都会求值,defaultdict 工厂是惰性的,代价是 d[key] 读取会写入;分组用 setdefault 或 defaultdict(list),绝不能用 get+append;计数用 Counter 或 defaultdict(int),Counter 读缺失不写,defaultdict(int) 读缺失会写 0;缺失行为需要定制,用 __missing__ 子类;需要 pickle 或跨进程,避免 lambda 工厂;交给外部序列化或返回调用方时,用 dict(d) 转成普通 dict。把这些行为和性能数据放在一起看,选型就不再是凭印象,而是按“是否写回、是否惰性、是否探测、是否可序列化”这几个维度决定。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-9-29 14:48 , Processed in 0.022580 second(s), 18 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部