Python字典默认值KeyError与defaultdict避坑
在 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 次取最优。下面按代码实现、参数语义、常见坑和性能数据重新整理。name_for_userid = {
382: 'Alice',
590: 'Bob',
951: 'Dilbert',
}
def greeting(userid):
return 'Hi %s!' % name_for_userid
greeting(382) 返回 'Hi Alice!';greeting(3333) 抛 KeyError: 3333。实测异常类型是 KeyError,repr(e) 是 KeyError(3333),str(e) 是 '3333',也就是 KeyError.__str__ 返回裸值字符串。需求很明确:键缺失时拿到合理默认值,而不是让程序崩溃。
路线一:if key in d 判断,也就是 LBYL。写法先查再取:
def greeting_if_in(userid):
if userid in name_for_userid:
name = name_for_userid
else:
name = 'there'
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。
def greeting_try(userid):
try:
return 'Hi %s!' % name_for_userid
except KeyError:
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)。大多数场景的默认答案:
def greeting_get(userid):
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()):
calls = 0
def expensive_default():
global calls
calls += 1
return 'GUEST'
d = {}
for _ in range(5):
d.get(404, expensive_default())
print(calls)# 实测 5
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 判断,或换哨兵对象:
_MISSING = object()
value = d.get(k, _MISSING)
if value is _MISSING:
pass# 键确实不存在
路线四:dict.setdefault(key, default)。语义比 get 多一步:键不存在时把默认值写进字典并返回;键已存在则直接返回已有值,不覆盖。
d = {'a': 1}
r1 = d.setdefault('a', 99)# 实测 1,不覆盖
r2 = d.setdefault('b', 99)# 实测 99,插入并返回
print(d) # 实测 {'a': 1, 'b': 99}
键存在但值是 None 时也不会被覆盖,因为 setdefault 判断的是键是否在字典里,而不是值是否为假:
d = {'a': None}
d.setdefault('a', 5)# 返回 None,字典仍为 {'a': None}
典型用法是分组:
words = ['apple', 'banana', 'avocado', 'blueberry', 'cherry']
groups = {}
for w in words:
groups.setdefault(w, []).append(w)
print(groups)
# 实测 {'a': ['apple', 'avocado'], 'b': ['banana', 'blueberry'], 'c': ['cherry']}
setdefault 返回的是字典里那个列表对象的引用,不是副本,所以 append 改的是字典里的真身。
头号坑是 get+append 会静默丢数据:
d7 = {}
d7.get('x', []).append(1)
d7.get('x', []).append(2)
print(d7)# 实测 {}
原因:get 每次返回新建的临时列表,append 改完后没有引用,直接被垃圾回收,字典自始至终没被写过。要取不到就创建并写回,用 setdefault 或 defaultdict,绝不能用 get。次要坑是默认值每次构造:对已存在的键连续调用 1000 次 setdefault(1, TrackedList()),TrackedList.__init__ 会被调用 1000 次,构造出 1000 个随后丢弃的列表。
路线五:collections.defaultdict。它用工厂函数取代固定默认值,把缺失时怎么办下沉到容器里。
from collections import defaultdict
d = defaultdict(list)
当通过 d 访问缺失键时,defaultdict 调用 default_factory() 生成默认值、写入字典、然后返回。defaultdict(list) 适合分组:
groups2 = defaultdict(list)
for w in words:
groups2].append(w)
# 实测 {'a': ['apple', 'avocado'], 'b': ['banana', 'blueberry'], 'c': ['cherry']}
比 setdefault 少一次方法调用。实测不同键拿到的列表互相独立:d['p'] is not d['q'] 为 True,内容各自独立。defaultdict(int) 适合计数:
counter = defaultdict(int)
for ch in 'abracadabra':
counter += 1
# 实测 {'a': 5, 'b': 2, 'c': 1, 'd': 1, 'r': 2}
# 实测 sum(counter.values()) -> 11
int() 返回 0,所以 counter += 1 在键缺失时能从 0 开始累加。常见工厂差异:defaultdict(list) 默认 [],用于分组、邻接表、一对多索引,每次缺失键都会新建空列表;defaultdict(int) 默认 0,用于计数、累加、词频,布尔语境下 0 为假需注意;defaultdict(lambda: 默认值) 可返回任意默认值,但无法 pickle。
核心坑:defaultdict 的读取会写入字典。
dd = defaultdict(list)
print(len(dd))# 实测 0
dd['missing_key']# 只是看一眼
print(len(dd))# 实测 1
print(dict(dd)) # 实测 {'missing_key': []}
哪些操作会触发工厂?实测 defaultdict(int) 下:d 会调用工厂并写入字典,缺失键读取后 len 从 0 变 1;d.get(k) 不会,返回 None 且 len 保持 0;k in d 不会,返回 False 且 len 保持 0;d.setdefault(k, v) 不会走工厂,使用传入的 v。也就是说只有下标访问 d 才会触发工厂。需要探测但不污染时,用 in 或 get。
defaultdict(None) 等价于普通 dict,缺失键依旧抛 KeyError:
nd = defaultdict(None)
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 工厂,换成命名函数或自定义类:
def make_default():
return {'n': 0}
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 时触发。
class DefaultingDict(dict):
def __missing__(self, key):
return f'<没有 {key} 这个键>'
md = DefaultingDict({'a': 1})
before = dict(md)
print(md['zzz']) # 实测 <没有 zzz 这个键>
print(dict(md) == before)# 实测 True,字典没有被污染
print('zzz' in md) # 实测 False
print(md.get('zzz', 'GET_FALLBACK'))# 实测 GET_FALLBACK
get 和 setdefault 不会触发 __missing__。setdefault 只把它自己传的默认值写进去;__missing__ 是 [] 专用通道。三种实用实现:A. 只读默认,不写回字典,适合探测式访问;B. 自动建容器,等价 defaultdict(list),但行为可见可控:
class AutoListDict(dict):
def __missing__(self, key):
value = []
self = value
return value
ad = AutoListDict()
ad['x'].append(1)# 实测 {'x': }
它比 defaultdict(lambda: []) 更好的一点是可 pickle,因为它是模块级类,以后加日志、统计也方便。C. 大小写不敏感字典:
class CaseInsensitiveDict(dict):
def __missing__(self, key):
if isinstance(key, str):
return self
raise KeyError(key)
def __setitem__(self, key, value):
if isinstance(key, str):
key = key.upper()
super().__setitem__(key, value)
ci = CaseInsensitiveDict()
ci['Name'] = 'Alice'
# 实测 ci['name'] -> 'Alice'
# 实测 ci['NAME'] -> 'Alice'
# 实测 ci.keys() -> ['NAME']
defaultdict 与 __missing__ 怎么选:defaultdict 默认值来源单一工厂,所有键相同,固定写入,代码一行,可读性高;__missing__ 可以按键逐个计算,是否写入由你决定,需要写一个类,可加日志、统计、类型校验、缓存。一句话:所有缺失键用同一种默认值,用 defaultdict;缺失时的行为需要定制,用 __missing__。
路线七:collections.Counter。Counter 是 dict 的子类,实测 isinstance(Counter(), dict) 为 True,专为计数而生。它与 defaultdict(int) 的关键差异是:读缺失键不写入。
from collections import Counter
c = Counter('abracadabra')
print(len(c)) # 实测 5
v = c['not_present'] # 实测 0
print(len(c)) # 实测 5,没有变
c.get('also_absent') # 实测 None,注意不是 0
'yet_another_absent' in c# 实测 False
print(len(c)) # 实测 5
Counter 的 __missing__ 直接返回 0 而不写入,所以不会被探查式读取污染。对照 defaultdict(int):d['missing'] 会让 len 从 0 变 1。注意 Counter 的 c 返回 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 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 读取会写入;分组用 setdefault 或 defaultdict(list),绝不能用 get+append;计数用 Counter 或 defaultdict(int),Counter 读缺失不写,defaultdict(int) 读缺失会写 0;缺失行为需要定制,用 __missing__ 子类;需要 pickle 或跨进程,避免 lambda 工厂;交给外部序列化或返回调用方时,用 dict(d) 转成普通 dict。把这些行为和性能数据放在一起看,选型就不再是凭印象,而是按“是否写回、是否惰性、是否探测、是否可序列化”这几个维度决定。
Re: Python字典默认值KeyError与defaultdict避坑
感谢整理,实测数据很有参考价值。我最深的两个点:一是 get 和 setdefault 的默认值参数都不是惰性的,哪怕键存在也会先把默认值表达式算一遍,所以默认值位置千万别放列表、函数调用、datetime.now() 这类构造代价大的东西,不然白白浪费;二是 get(k) 返回 None 时确实没法区分“键不存在”和“键存在但值为 None”,如果 None 是合法业务值,要么再配合 in 判断,要么用 object() 哨兵。 选型上我自己的感觉也是:只读取默认值就用 get,稳定、一次查找、不写字典;键缺失很少且命中是热点,try/except 确实快,但缺失一多,traceback 构造的代价就上来了;需要写回字典或分组时,setdefault 语义清楚,但默认值同样会提前求值,重对象要小心。LBYL 那种 if key in d 的写法虽然多一次哈希查找,但判断本身有额外意义时反而更直观。 期待后面 defaultdict、__missing__ 和 Counter 的对比,尤其是 defaultdict 惰性构造和 setdefault 在分组场景下的性能差异。Re: Python字典默认值KeyError与defaultdict避坑
感谢整理,这七条路线和实测数据很有参考价值。我平时最常用 dict.get,读完更清楚它只读、一次查找,但默认值参数会无条件求值这个坑确实容易踩,尤其默认值写成 list 或函数调用时,键命中也白构造,代价很大。None 歧义那段也很实用,业务里值本身可能为 None 时,单靠 get 返回 None 判断不了键是否存在,用哨兵对象更稳。try/except 命中第一但缺失慢到 90.9 ns,确实只适合缺失极少的路径;if in 虽然命中要两次哈希,但逻辑直白,适合不存在时还要报错或记日志的场景。setdefault 不覆盖已有值、也不覆盖 None 这点讲得很清楚。后面 defaultdict、__missing__ 和 Counter 的部分好像还没展开,尤其分组那个例子停在了 for w in words,期待补完,也想看惰性构造和性能对比。Re: Python字典默认值KeyError与defaultdict避坑
感谢整理,很实用。把 KeyError、get、setdefault、defaultdict、__missing__ 和 Counter 放在一起对比,再加上真机 benchmark 数据,看起来很直观。 我平时最常用 get,但确实容易忽略默认值参数无论键是否存在都会求值,带副作用或临时构造大容器时白白浪费;None 无法区分“键不存在”和“键存在但值为 None”这个坑也踩过,用 _MISSING 哨兵确实更稳。try/except 命中快、缺失慢这个结论也很有启发,适合缺失罕见的场景。setdefault 会写入且不覆盖已有键、值为 None 也不覆盖,语义讲得很清楚。 分组那段代码好像还没贴完,期待后续。
页:
[1]