前言
collections是标准库里最常被低估的模块之一。它的六个主力容器——Counter、defaultdict、deque、namedtuple、OrderedDict、ChainMap——各自解决一类手写代码既啰嗦又容易出错的问题。很多人用了一辈子 dict 加get来计数,却不知道Counter一行就能搞定,还自带most_common。
一个常见误解,是以为collections里的容器是「内置容器的高级版」,用哪个都行。事实恰恰相反:它们是专用工具,各自有明确的适用边界。defaultdict在in判断上会引入副作用;Counter访问不存在的键返回 0 而不报错;deque适合两端操作但随机访问慢;OrderedDict在普通 dict 已经保序之后,它的价值只剩「有序性参与相等比较」和move_to_end这几处了。
本文按容器逐个讲定位和典型用法,每个都给一段能直接复制运行的骨架。所有示例以 Python 3.8 及以上的写法为准。
一、Counter:一行做计数
Counter是dict的子类,专门用来统计可哈希对象出现的次数。它的值是计数,可以是任意整数,包括 0 和负数。
# 适用于 Python 3.8+
from collections import Counter
words = ["a", "b", "a", "c", "a", "b"]
c = Counter(words)
print(c) # Counter({'a': 3, 'b': 2, 'c': 1})
print(c["a"]) # 3
print(c["z"]) # 0,不存在的键返回 0,不抛 KeyError
print(c.most_common(2)) # [('a', 3), ('b', 2)]
# 从可迭代对象、映射、关键字参数三种来源构造
print(Counter("abracadabra").most_common(2)) # [('a', 5), ('b', 2)]
print(Counter({"red": 4, "blue": 2}))
print(Counter(cats=4, dogs=8))
# 计数可以原地加减,也可以做多重集运算
c.update(["a", "z"]) # 按数量累加
print(c["a"], c["z"]) # 4 1
c.subtract({"a": 1}) # 按数量扣减,允许出现 0 或负数
print(c["a"]) # 3most_common(n)返回计数最高的 n 个键值对,按计数降序、同计数按首次出现顺序。不传参数时返回全部。元素运算里+是计数相加、-是相减且只保留正数、&取逐键最小值、|取逐键最大值。elements()会把每个元素按其计数重复产出,计数小于 1 的会被忽略。较新的 CPython 还提供了total(),用来求所有计数之和;如果你的运行环境里没有这个方法,等价写法是sum(c.values())。
二、defaultdict:消灭「不存在就初始化」
分组(把元素按某个特征归到不同的列表里)是最典型的场景。用普通 dict 写,每轮都要判断键在不在;defaultdict把这一步省掉:访问不存在的键时,它先调用default_factory生成默认值、写进字典,再返回。
# 适用于 Python 3.8+
from collections import defaultdict
rows = [("fruit", "apple"), ("veg", "carrot"), ("fruit", "pear")]
grouped = defaultdict(list)
for k, v in rows:
grouped[k].append(v) # 键不存在时自动得到空列表
print(dict(grouped)) # {'fruit': ['apple', 'pear'], 'veg': ['carrot']}
# default_factory 传 int 时,默认值是 0,天然适合累加
counter = defaultdict(int)
for k, _ in rows:
counter[k] += 1
print(dict(counter)) # {'fruit': 2, 'veg': 1}
# 传 set 时可以做去重分组
by_first = defaultdict(set)
for k, v in rows:
by_first[k].add(v[0])
print(dict(by_first)) # {'fruit': {'a', 'p'}, 'veg': {'c'}}关键点:defaultdict的第一个参数是一个可调用对象(工厂函数),不是默认值本身。传int得到 0、传list得到空列表、传set得到空集合。传0或[]会抛TypeError,因为它们不可调用。不传参数时,缺失键的行为跟普通 dict 一样抛KeyError。
三、deque:两端都是 O(1)
deque(double-ended queue,双端队列)支持在两端高效地增减元素,两端的 append 和 pop 都是 O(1)。它还支持maxlen参数,满了以后从另一端自动丢弃,特别适合「只保留最近 N 条」的场景。
# 适用于 Python 3.8+
from collections import deque
d = deque([1, 2, 3])
d.append(4) # 右端加
d.appendleft(0) # 左端加
print(list(d)) # [0, 1, 2, 3, 4]
print(d.pop()) # 4,右端弹出
print(d.popleft()) # 0,左端弹出
d.rotate(1) # 整体右移一格
print(list(d)) # [3, 1, 2]
# maxlen:固定窗口,满了自动丢最旧的
recent = deque(maxlen=3)
for i in range(5):
recent.append(i)
print(list(recent)) # [2, 3, 4]方案对比:用 list 当队列时,尾部 append 是均摊 O(1),但头部pop(0)要挪动全部剩余元素,是 O(n)。deque 的popleft()是 O(1)。deque 的代价在于随机访问:按下标取中间的元素会退化为 O(n),两端附近才是 O(1)。所以「频繁按下标读」要用 list,「频繁两端进出」才用 deque。
四、namedtuple:给元组字段起名
namedtuple生成一个 tuple 的子类,既有元组的不可变、可解包、可哈希,又能用obj.field按名字访问,比裸元组可读得多。
# 适用于 Python 3.8+
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 5)
print(p.x, p.y) # 3 5,按名字访问
print(p[0], p[1]) # 3 5,按下标访问
x, y = p # 依然可以解包
print(p == (3, 5)) # True,它就是元组
# _replace 返回新实例,不修改原对象
q = p._replace(x=10)
print(p, q) # Point(x=3, y=5) Point(x=10, y=5)
print(p._asdict()) # {'x': 3, 'y': 5}
print(Point._fields) # ('x', 'y')
# 带默认值(defaults 从最右边的字段开始生效)
Task = namedtuple("Task", ["name", "priority", "done"], defaults=[1, False])
print(Task("write")) # Task(name='write', priority=1, done=False)字段名不能是 Python 关键字,也不能以下划线开头(避免与_fields、_replace这类自带名字冲突)。defaults关键字参数从 Python 3.7 起才有。
五、OrderedDict 与 ChainMap
OrderedDict是「记住顺序」的字典。在 Python 3.7 之后普通 dict 也保证插入顺序,所以它的独有价值收缩到两点:一是move_to_end(key, last=True)可以把某个键移到开头或末尾;二是它的相等比较是顺序敏感的——两个元素相同但顺序不同的 OrderedDict 不相等,而两个 dict 只要内容相同就相等。
# 适用于 Python 3.8+
from collections import OrderedDict, ChainMap
od = OrderedDict([("a", 1), ("b", 2), ("c", 3)])
od.move_to_end("a") # 把 a 挪到末尾
print(list(od.keys())) # ['b', 'c', 'a']
print(od.popitem(last=False)) # ('b', 2),从头弹出
print(OrderedDict([("a", 1), ("b", 2)]) == OrderedDict([("b", 2), ("a", 1)])) # False
# ChainMap:把多个映射串成一条查找链,从前到后找第一个命中
defaults = {"color": "black", "size": "M"}
user = {"color": "red"}
merged = ChainMap(user, defaults)
print(merged["color"]) # red,user 优先
print(merged["size"]) # M,回退到 defaults
print(list(merged)) # ['color', 'size']ChainMap的写操作只作用于第一个映射,del也一样。它适合「多层配置叠加」:命令行参数 > 环境变量 > 默认值。它的new_child()返回一个在前端插入新空映射的新 ChainMap,parents返回去掉第一层的视图。
| 容器 | 一句话定位 | 别用它来 |
|---|
| Counter | 计数与多重集运算 | 存非计数类的值 |
| defaultdict | 分组、累加,免去初始化判断 | 需要「不写入」的探测式访问 |
| deque | 两端进出的队列 / 固定窗口 | 频繁随机下标访问 |
| namedtuple | 轻量不可变记录 | 需要频繁改字段 |
| OrderedDict | 需要 move_to_end 或顺序敏感比较 | 只需要普通保序 |
| ChainMap | 多层配置叠加查找 | 需要合并成一份真实数据 |
常见坑点
- 用
in去探测 defaultdict,反而把键写进去了。
❌if key in dd:之后又dd[key]——看似无事,但如果换成直接dd[key]探测,就会在字典里凭空多出这个键。
✅ 只想探测就老实用dd.get(key)或if key in dd,别用下标访问做「试探」。
- 给 defaultdict 传了默认值而不是工厂。
❌defaultdict([])—— 抛TypeError: first argument must be callable or None。
✅ 传类型本身:defaultdict(list)、defaultdict(set)、defaultdict(int)。
- 以为 Counter 访问不存在的键会报错。
❌ 依赖KeyError来判断某元素是否出现过——Counter返回的是 0,不会抛异常。
✅ 判断「出现过没有」用if w in counter,或者看counter[w] > 0。
- 以为把计数设为 0 就删掉了元素。
❌c["x"] = 0之后"x" in c仍是True,遍历时它还在。
✅ 要真正移除用del c["x"],或c = +c(一元加号会把非正计数的项丢掉)。
- 用 deque 频繁按下标取中间元素。
❌d[1000]在长 deque 上是 O(n),看着像 list 用法其实慢得多。
✅ 需要随机访问就用 list;deque 只在两端进出时才有优势。
- namedtuple 的字段名不合法。
❌namedtuple("T", ["class", "1st"])—— 关键字和数字开头都不允许,抛ValueError。
✅ 字段名用合法标识符,需要「避开关键字」时常见做法是加下划线,如class_。
- 以为 ChainMap 写进去的会落到所有层。
❌merged["size"] = "L"只改了user这一层,defaults纹丝不动。
✅ 想真正合并成一份数据,先dict(merged);想改某一层就显式操作那一层的映射。
- 以为 OrderedDict 和 dict 完全等价。
❌ 拿两个顺序不同的 OrderedDict 做==,期待像 dict 一样相等——结果是False。
✅ 需要「顺序不参与比较」就用普通 dict;需要顺序敏感比较才用 OrderedDict。
总结
| 需求 | 推荐容器 | 关键方法 |
|---|
| 统计频次 | Counter | most_common/elements/total |
| 分组、累加 | defaultdict | default_factory |
| 队列、双端队列 | deque | appendleft/popleft/maxlen |
| 带字段名的记录 | namedtuple | _replace/_asdict/_fields |
| 需要重排顺序 | OrderedDict | move_to_end/popitem(last=...) |
| 多层配置查找 | ChainMap | new_child/parents |
collections的价值在于「把常见模式固化成一个有名字的类」。判断该不该用它,只看一条:这段手写逻辑是不是反复出现、是不是容易写错。是的话,标准库大概率已经替你封装好了。