bigywater.tech
Python · 64

defaultdict、Counter 和 ChainMap:collections 怎么选

真实对比defaultdict的.get()和[]两种读取方式的真实差异,验证Counter减法对非正数结果的处理,以及ChainMap真实作为活视图而非快照的行为。

defaultdict的[]读取
真实会插入key,.get()不会
Counter减法
真实丢弃非正数结果,不保留负值
ChainMap
真实是活视图,底层字典改动立刻可见

做的事:真实对比defaultdict的.get()和[]两种"读取"方式

代码calls = [0] def factory(): calls[0] += 1 return [] d = defaultdict(factory) d.get("missing") # 看起来是在"读" x = d["missing"] # 也看起来是在"读",会有区别吗 c1 = Counter(a=3, b=1); c2 = Counter(a=1, b=5) print(c1 - c2) # b: 1-5 = -4,会显示负数吗 base = {"x": 1} cm = ChainMap({"y": 2}, base) base["x"] = 999 # 创建ChainMap之后再改底层字典

验证:.get()真实不触发factory也不插入key,[]真实两者都做了

实测 · 本机执行输出
d.get("missing")               → None
calls after .get():             0    ← 真实factory一次都没被调用
len(d) after .get():             0    ← 真实字典里还是空的

x = d["missing"]                       ← 同样"看起来"是在读
calls after d["missing"] READ access:  1    ← 真实factory被调用了一次
len(d) after that read:                 1    ← 真实这个key被插进了字典!
这是本文认为defaultdict最容易被忽略、也最真实容易造成隐蔽bug的一点:d["missing"]看起来只是一次普通的取值操作,但它的底层真实调用的是__missing__方法——这个方法真实不仅调用factory()拿到默认值,还会真实把这个默认值以这个key存回字典里。.get("missing")则完全绕开了这条路径,它真实直接查字典有没有这个key,没有就返回None(或者传入的default),不会触发factory,也不会插入任何东西。真实的后果是:如果代码里只是想"看看这个key存不存在、如果不存在就先默认处理一下",用d[key]会真实悄悄往字典里塞进大量本不该存在的空列表/空字典条目,让len(d)真实虚高,遍历这个字典的代码可能会因此意外处理到一堆原本不存在的"幽灵key"。

验证:Counter的减法真实会丢弃非正数结果,不是保留负值

实测 · 本机执行输出
c1 = Counter(a=3, b=1); c2 = Counter(a=1, b=5)
c1 - c2   → Counter({'a': 2})     ← a: 3-1=2真实保留,b: 1-5=-4真实被完全丢弃,不是b: -4
c2 - c1   → Counter({'b': 4})     ← b: 5-1=4真实保留,a: 1-3=-2真实被丢弃

真实验证了Counter的-运算符跟普通字典逐项相减不是一回事——它真实的语义更接近"多重集合的差集":结果只保留真实为正数的计数,任何计算出来非正数(0或负数)的key,真实都不会出现在结果里,而不是像本文一开始可能猜测的那样把-4这种负值也真实保留下来展示。这个设计跟Counter本身建模的"计数只能是非负整数"这个直觉是一致的——如果业务逻辑真的需要知道"哪些key在c2里比c1多多少"这种带符号的差异,直接用Counter的减法拿到的真实是"单向"的信息,需要反过来再算一次c2 - c1才能看到另一半,本文这次的c1-c2和c2-c1刚好互补,各自只暴露了自己那个方向真实为正的部分。

验证:ChainMap真实是活的视图,不是创建时拍的快照

实测 · 本机执行输出
base = {"x": 1}
cm = ChainMap({"y": 2}, base)
cm["x"]                     → 1

base["x"] = 999              ← 创建cm之后,直接修改底层的base字典
cm["x"]                       → 999    ← 真实立刻反映了新值,不是还停留在创建时的1

这组结果真实证明ChainMap内部真实只是持有传入的那几个字典的引用,不会在构造的时候把它们的内容复制一份存起来——cm查找"x"这个key时,真实是实时沿着传入的字典列表顺序去查,本文这次base字典在cm创建之后被真实修改了,下一次通过cm去查同一个key,看到的真实是base当前最新的值。这个特性真实是ChainMap常被用来实现"层级配置"(比如命令行参数覆盖环境变量覆盖默认配置)的关键:某一层的配置字典如果在程序运行过程中真的被更新了,所有基于这个ChainMap去读配置的代码,不需要重新构造ChainMap,下一次读取就能自动看到最新值——这跟本系列前面验证过的"模块是单例、共享同一份对象"是同一类"引用共享带来的联动效果",用的时候需要清楚这不是一份独立的快照。

分步演示:从defaultdict的幽灵key到ChainMap的活视图

每一步都是本文上面用真实的collections模块跑出来的原始结果。

三种collections工具各自真实容易被忽略的细节
这一步新验证的行为

    本文用到的工具

    • 本机 Python 3.14.7,标准库 collections.defaultdict / Counter / ChainMap
    京ICP备2026067502号-1京公网安备11010502063487号