2460 字
约 8 分钟
15
S10-A19 · `collections` 的四个边界:Counter、defaultdict、deque 与 OrderedDict

一段统计代码用 Counter 累加,然后按值排序取 Top 10。上线后发现"并列时顺序随机",同一份数据两次跑出的 Top 10 不一样——因为它按"插入顺序"打破并列,而插入顺序取决于数据到达顺序。

collections 里的工具都"看起来简单",而它们的边界恰好在"并列、默认值、线程、内存"这几个地方。它们还有个共同的"温和"特征:不抛异常、不报错,只是给出一个"可能不同"或"悄悄多了一条"的结果。于是问题要到"对账"或"复现"时才被看见。
而这些地方正是"结果看起来对、实际不可复现"的来源。


1. Counter 的三个边界

边界 说明
并列的排序不稳定 most_common 按插入顺序
缺失键返回 0 而非报错 掩盖拼写错误
与普通 dict 运算的语义 + 会丢负数

第一项最实际:most_common(n) 在并列时不保证顺序。第一项的机制是"排序实现通常不保证并列项的相对次序",而它取决于"输入的产生顺序"。于是"同一份数据、不同到达顺序"会得到不同的 Top 10。
要"可复现的 Top N",必须自己排序并给出稳定的次序(如 (-count, key))。

第二项在"统计缺失值"的场景里反而是特性:"某类完全没出现"与"出现 0 次"在计数上等价,于是报表里"全 0"是正常的。
第二项是"方便也是隐患":c["typo"] 返回 0,于是拼错的键静默计 0,而统计结果"少了几个"没人发现。

// 反例:并列时顺序不稳
top = Counter(words).most_common(10)

// 正解:显式排序,给出稳定的次序
c = Counter(words)
top = sorted(c.items(), key=lambda kv: (-kv[1], kv[0]))[:10]
// 关键:并列时按 key 排序 → 同一份输入永远得到同一份输出

2. defaultdict 的"自动创建"

场景 后果
dd[k].append(v) 自动建空 list,正常
k in dd 判断 不会创建(好)
误用 dd[k] 做存在性检查 会创建空值(脏数据)
序列化/拷贝 工厂函数被带过去

第三项是"副作用":if dd[k] 这类检查会创建条目第四项(拷贝/序列化)还有一个"陷阱":json.dumps(defaultdict) 会失败(它不是标准类型),于是"能跑的场景"与"要序列化的场景"行为不同。
,于是"检查存在性"变成了"插入空值"。而它会让"键数量"统计失真。

第四项在 copy.copy / 序列化时出现:工厂函数被复制,于是"复制的字典里也有自动创建行为"——这在"传给外部"时可能意外写入。

// 反例:用 [] 做"存在性检查",意外创建
if dd[key]:                       # 创建了 key → []
    ...
// 正解:先判断键
if key in dd and dd[key]:
    ...

3. deque 的边界:容量与线程

用法 边界
deque(maxlen=N) 满了自动丢最旧(O(1))
append/popleft O(1)
中间插入 O(n)(deque 不适合)
线程安全 单操作原子(append/popleft),组合不原子

第一项很实用:maxlen 让"最近 N 条"变成一行代码。但要清楚它"丢弃时不报错"——于是"我明明放进去 100 条,怎么只剩 50"会成为困惑。

第三项(中间插入是 O(n))在"需要按优先级插队"时不合适:deque 只擅长两端,而"插到中间"应当换 heapq 或有序结构。
第四项是最容易误解的:deque.append 是原子的,但 if len(d)>0: d.popleft() 不是。所以"生产者-消费者"用 deque 仍需显式同步,或直接用 queue.Queue。

// 原子 vs 非原子
d = deque(maxlen=100)
d.append(x)                 # 原子
if d: y = d.popleft()        # 非原子(两步之间可能被清空 → IndexError)
// 正解:捕获异常或用 queue.Queue
try:
    y = d.popleft()
except IndexError:
    y = None

4. OrderedDict 与"普通 dict 的差别"

维度 普通 dict(3.7+) OrderedDict
保插入序 是 是
move_to_end 无 有
popitem(last=False) 无(只有 LIFO) 有
相等性比较 忽略顺序 考虑顺序

第四项是"语义差异"的关键:两个内容相同但插入顺序不同的 OrderedDict 不相等第二项(move_to_end)在"实现 LRU"时有用:命中就移到末尾,淘汰取头部。而普通 dict 没有这个方法,只能"删除再插入"——那会改变其它迭代器的行为。
,而普通 dict 相等。于是"用 OrderedDict 当缓存键"会出现"看起来一样但不相等"的问题。

第一项意味着"现在不需要 OrderedDict 来保序":绝大多数场景用普通 dict 即可,只有需要 move_to_end 或"顺序敏感比较"时才需要它。

// 什么时候真的需要 OrderedDict
d = OrderedDict()
d["a"] = 1; d["b"] = 2
d.move_to_end("a")           # 普通 dict 没有这个
// 相等性:顺序敏感
OrderedDict([("a",1),("b",2)]) == OrderedDict([("b",2),("a",1)])   # False
{"a":1,"b":2} == {"b":2,"a":1}                                     # True

还有一种"看似等价"的替换要小心:用 dict.setdefault 替代 defaultdict。语义相近,但 setdefault 每次都构造默认值(哪怕不用),于是"高频调用 + 昂贵默认值"时会明显变慢。

// defaultdict:默认值只在缺失时构造
dd = defaultdict(list); dd[k].append(v)

// setdefault:每次调用都先构造 list(),再判断
d.setdefault(k, []).append(v)      # 多一个空 list 的分配

// 更省的写法(只构造一次)
if k not in d: d[k] = []
d[k].append(v)

5. 内存与"计数精度"

场景 建议
高频计数(百万键) Counter 可以,但注意内存
只需 Top N 用"固定大小堆"而不是全量 Counter
大整数计数 Python int 自动扩展(无溢出)
并发计数 需锁或分片

第二项在"数据量大"时很实际:全量 Counter 会保留所有键,而"只需要 Top 10"用 heapq.nlargest 配合有界结构更省内存。

第二项(固定大小堆)还有个"精度"细节:Top N 用堆时,并列项仍需一个稳定次序,否则每次结果不同——这与 Counter 的问题同源。
第三项是 Python 的优势:计数不会溢出,但"数字变得极大"会让内存与比较变慢——而它不报错。

"可复现"这件事可以做成一条测试:同一份输入跑两次,断言输出完全相同。这条测试对"Top N/排序/去重"类逻辑特别有效,而它几乎不花时间。

// 可复现性测试(针对并列顺序)
def test_top10_stable():
    data = ["b","a","c","a","b","c"]        # 三个并列
    r1 = top_n(data, 3); r2 = top_n(data, 3)
    assert r1 == r2 == [("a",2),("b",2),("c",2)]   # 稳定按 key 排

6. 排错:五种"collections 用错"

现象 成因 处理方向
Top N 每次不同 并列顺序不稳 显式排序
键数量比预期多 dd[k] 创建空值 用 in 判断
放进去的丢了 maxlen 静默丢弃 检查容量
偶发 IndexError if d: popleft 非原子 捕获或用 Queue
相等判断不符预期 OrderedDict 顺序敏感 用普通 dict 或显式转

第三项最容易被当成 bug:"最近 N 条"的设计意图是"只保留 N",但使用者常以为"不丢"。第四项(IndexError)在"多线程消费"里最常见:两个线程都看到 len>0,其中一个先取走,另一个就报错。这类问题表现为"偶发",而日志里只看到一行 IndexError。

最后一个"内存"实践:百万级键的 Counter 要算内存。一个键值对(字符串键 + int)在 Python 里可能占 ~100 字节以上,于是"100 万键"就是 100MB 级——而"只需要 Top 10"完全不必保留全部。

// 三种规模下的策略
< 1 万键:Counter 全量 + 显式排序,简单可靠
1 万–100 万:Counter 全量但定期裁剪,或用有界结构
> 100 万:流式 Top N(heapq.nlargest 配合分片合并)

7. 边界:什么情况不必用 collections

场景 为什么 做法
简单 dict/list 够用 无需特殊行为 直接用内置
需要线程安全的队列 deque 不够 queue.Queue
需要有序且频繁改序 普通 dict + 重建 视规模而定
只需计数一次 简单循环 不必 Counter

最后一条与"可复现"有关的边界:凡是"顺序影响结果"的地方,都应当显式排序。因为"输入顺序"往往不由你控制(并发、分片、重试),而"结果应当确定"是基本要求。
判断口径一句话:这个结构需不需要"自动行为"(默认值、丢最旧、保序差异)。需要,用对应工具并清楚它的边界;不需要,内置类型更少惊喜。


8. 自测

  1. Counter.most_common 的并列顺序为什么"不稳"?怎么得到可复现的 Top N?
  2. defaultdict 的"自动创建"在哪种用法下会制造脏数据?怎么正确判断存在性?
  3. deque(maxlen) 的"静默丢弃"会造成什么误解?
  4. 为什么 if d: d.popleft() 不是原子操作?正确写法是什么?
  5. OrderedDict 与普通 dict 的三处语义差异是什么?哪一处会导致"看起来一样但不相等"?
  6. 判断"该不该用 collections"的那个问题是什么?
S10-A19 · `collections` 的四个边界:Counter、defaultdict、deque 与 OrderedDict
https://www.clxhxhhr.top/posts/6550/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。