# Python中的defaultdict:一个让字典操作更优雅的利器
有时候,写代码时最烦人的事情莫过于处理字典里不存在的键。每次都要写if key in dict或者try-except,感觉就像在洗菜时反复检查有没有泥土——这事总得做,但做多了就烦。
它到底是什么
defaultdict是collections模块里的一个字典子类。它的核心想法很朴素:当访问一个不存在的键时,不会抛出KeyError,而是调用一个预先设定好的工厂函数,自动生成一个默认值。
举个例子,在普通的字典里,如果尝试访问my_dict[‘name’]但’name’这个键不存在,Python就会毫不留情地报错。而defaultdict会悄悄地说:“哦,这个键还没有啊,那我先给你创建一个,放个默认值进去。”
这个区别就像是在柜台买咖啡:普通柜台要你先付钱再拿咖啡,没付钱就不给;而defaultdict像是自动售货机,你按了按钮,它会先做好一杯给你,再问你记不记账。
它能解决什么问题
最典型的场景是统计频率。比如要统计一篇文章里每个单词出现了几次:
# 普通字典的做法word_count={}forwordintext.split():ifwordinword_count:word_count[word]+=1else:word_count[word]=1# 用defaultdictfromcollectionsimportdefaultdict word_count=defaultdict(int)forwordintext.split():word_count[word]+=1这看上去只是少写了几行代码。但真正的好处在于,当这种模式出现在更复杂的逻辑里时,它会大大减少代码的嵌套和分支。比如要统计一本书里每个字母出现在哪些单词中:
# 普通字典letter_words={}forwordinwords:first_letter=word[0].lower()iffirst_letternotinletter_words:letter_words[first_letter]=[]letter_words[first_letter].append(word)# 用defaultdictletter_words=defaultdict(list)forwordinwords:letter_words[word[0].lower()].append(word)第二个版本读起来几乎就是自然语言:“对于每个单词,以字母为首,把单词放进去。”不需要纠结于检查键是否存在。
另一个常见场景是构建多层嵌套的数据结构。比如要统计每年的每个月的销售额:
# 想都不想就直接写sales=defaultdict(lambda:defaultdict(float))sales[2023][12]+=1500.0sales[2023][11]+=2300.0这种写法很舒服,因为你在建立数据的过程中可以完全专注于业务逻辑,而不是数据结构本身的管理。
怎么使用它
使用defaultdict很简单,导入后传入一个可调用对象作为参数:
fromcollectionsimportdefaultdict# 各种常用类型d_int=defaultdict(int)# 默认值为0d_list=defaultdict(list)# 默认值为空列表d_dict=defaultdict(dict)# 默认值为空字典d_set=defaultdict(set)# 默认值为空集合d_str=defaultdict(str)# 默认值为空字符串d_float=defaultdict(float)# 默认值为0.0d_bool=defaultdict(bool)# 默认值为Falsed_bytes=defaultdict(bytes)# 默认值为空字节串需要注意的是,传入的必须是可调用对象。这意味着不能直接传一个值,而是传一个函数或类型:
# 正确d=defaultdict(int)# 错误,会报错d=defaultdict(0)# 但可以这样d=defaultdict(lambda:0)对于复杂的默认值,使用lambda表达式很灵活:
# 默认值为包含两个元素的列表d=defaultdict(lambda:[0,0])# 默认值为特定字符串d=defaultdict(lambda:"未找到")# 默认值为一个自定义类的实例fromdataclassesimportdataclass@dataclassclassInventory:quantity:int=0price:float=0.0d=defaultdict(Inventory)有个小细节值得注意,defaultdict在访问不存在键时会调用工厂函数,但如果你用的是.get()方法,它不会触发这个机制:
d=defaultdict(int)print(d['a'])# 输出0,键'a'被创建print(d.get('b'))# 输出None,键'b'没有被创建一些实践中的体会
在实际项目中,我倾向于把defaultdict用在构建数据结构的阶段,而不是最终存储的容器。比如在整理分析数据时,先用defaultdict快速整理,完成后会把它转成普通字典,或者转成final类型,方便后续序列化或比较。
另一个使用场景是缓存。比如说要实现一个斐波那契数列的递归版本,普通写法:
fromfunctoolsimportlru_cache@lru_cache(maxsize=None)deffib(n):ifn<=1:returnnreturnfib(n-1)+fib(n-2)但如果你有特殊需求,比如要记录每个计算结果被访问的次数,用defaultdict可以:
classFibonacci:def__init__(self):self.cache=defaultdict(lambda:None)self.access_count=defaultdict(int)def__call__(self,n):self.access_count[n]+=1ifn<=1:returnnifself.cache[n]isnotNone:returnself.cache[n]self.cache[n]=self(n-1)+self(n-2)returnself.cache[n]但说实话,defaultdict也有它的局限。它最让人不舒服的地方是:当你访问一个不存在的键时,它会悄无声息地创建一个条目。这在某些场景下会引入难以发现的bug。比如在检查键是否存在时,你只是想看看,结果却意外地创建了它:
d=defaultdict(list)ifd['key']:# 这里创建了一个空列表,改变了字典pass所以建议只在明确知道要“创建新条目”的情况下使用defaultdict,而不是简单地用于“避免KeyError”。
和其他技术的比较
与普通字典的比较
普通字典在访问不存在的键时会抛出异常,这对某些场景是好事——比如你要确保数据完整,那么异常就是一种警告。而defaultdict适合那种“构建数据”的场景,它的行为更像是“如果不存在就创建”。
与setdefault的比较
字典的setdefault方法提供了类似的功能:
d={}d.setdefault('key',[]).append('value')但这样写起来,每次都要重复写默认值。如果默认值是通过一个复杂的函数计算出来的,setdefault每次都会执行这个函数,哪怕键已经存在:
defexpensive():print("调用了一次")return[]d={}d.setdefault('a',expensive()).append(1)d.setdefault('a',expensive()).append(2)# "调用了一次"会被打印两次而defaultdict的工厂函数只在键不存在时才调用。
与Counter的比较
Counter专门用于计数,它继承自defaultdict,提供了most_common、subtract等方法。如果用defaultdict做计数,就必须手动实现这些功能。
fromcollectionsimportCounter# Counter的写法c=Counter('abracadabra')print(c.most_common(3))# [('a', 5), ('b', 2), ('r', 2)]# 用defaultdict实现同样的功能d=defaultdict(int)forcin'abracadabra':d[c]+=1# 但most_common需要自己实现sorted_items=sorted(d.items(),key=lambdax:x[1],reverse=True)[:3]所以如果只是计数,Counter更合适。但如果建复杂的数据结构,defaultdict更灵活。
与类属性默认值的比较
有时候用类属性来替代defaultdict:
classConfig:defaults=defaultdict(lambda:"默认值")def__init__(self):self.values={}def__getitem__(self,key):returnself.values.get(key,self.defaults[key])但这样仍然需要写很多基础设施。对于简单的场景,直接用defaultdict可能更省事。
defaultdict的价值不在于它多强大,而在于它让代码变得简洁、可读。在写Python代码时,它就像是工具箱里的一把好用的螺丝刀——不是每个螺丝都要用,但当你遇到合适的场景时,它能让工作变得轻松很多。