在 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[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[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[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[0], []).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[key] 访问缺失键时,defaultdict 调用 default_factory() 生成默认值、写入字典、然后返回。defaultdict(list) 适合分组:
- groups2 = defaultdict(list)
- for w in words:
- groups2[w[0]].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[ch] += 1
- # 实测 {'a': 5, 'b': 2, 'c': 1, 'd': 1, 'r': 2}
- # 实测 sum(counter.values()) -> 11
复制代码
int() 返回 0,所以 counter[ch] += 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[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:
- 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[key] 时触发。
- 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[key] = value
- return value
- ad = AutoListDict()
- ad['x'].append(1) # 实测 {'x': [1]}
复制代码
它比 defaultdict(lambda: []) 更好的一点是可 pickle,因为它是模块级类,以后加日志、统计也方便。C. 大小写不敏感字典:
- class CaseInsensitiveDict(dict):
- def __missing__(self, key):
- if isinstance(key, str):
- return self[key.upper()]
- 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[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。把这些行为和性能数据放在一起看,选型就不再是凭印象,而是按“是否写回、是否惰性、是否探测、是否可序列化”这几个维度决定。 |