引言
第六章走到第七篇,前六篇把内建容器讲透了:列表、元组、字典、集合的语法与操作(第一至四篇),拷贝与引用的内存真相(第五篇),以及嵌套容器的多维数据组织(第六篇)。本篇开始进入「组合运用」阶段的第一站:标准库 collections 模块。它解决的是内建容器在工程里的四个高频痛点——数不清出现次数、取不到缺省键、左右两端都要高效进出、数据需要字段名。真实代码里「数单词」「按类型分组」「保留最近 N 条记录」「数据库一行一行的记录」这些需求,几乎都能在 collections 里找到现成的容器。
读本篇前,你应当已经掌握:字典的键值操作与遍历(第三篇)、嵌套容器(第六篇)、以及「对象是引用」的意识(第五篇)。本篇所有代码在 Python 3.10+ 上运行,本机以 Python 3.12.10 实测,输出与执行结果完全一致。
概念与原理
collections 的定位:为内建容器打补丁
collections 模块是标准库中最常用的模块之一,它提供一批「容器变体」(container datatypes):namedtuple、Counter、defaultdict、deque 是四大主力,OrderedDict 与 ChainMap 也常被问到。共同的设计哲学是替换式:每种变体要么是 dict 的子类(Counter、defaultdict、OrderedDict、ChainMap 的一部分),要么可以在任何需要原类型的位置直接顶替(namedtuple 是 tuple 的子类、deque 是独立的序列类型)。因此你学过的 dict 方法、tuple 操作、序列切片,在这些容器上依然成立——collections 不做新语法,只是把高频工程模式包装成现成的类。
namedtuple:给元组起名字
namedtuple 的工厂函数接收「类型名 + 字段名列表」,返回一个新类。它仍然是元组:内存布局、解包、下标访问、哈希行为与普通 tuple 完全一致。字段名只是附加在类上的元数据,加上一组属性访问器。为什么不用字典?字典是按字符串键查找、可以修改;而记录型数据(坐标、一行数据库结果)天然是「数量固定、按名读取、不可篡改」的,元组的不可变与可哈希在这里是优点。为什么不用手写类?一个只有数据的类需要 init 样板代码,namedtuple 一行定义完事。Python 3.7 之后的 dataclasses 是更重的方案:适合「除了数据还要写方法、要默认值、要类型注解」的记录;而「只需要起个名字」时,namedtuple 仍然是最轻的选择。
Counter:把「手写计数循环」消灭掉
Counter 是 dict 的子类,专为「数东西」设计。核心行为有两条:第一,缺失键返回 0 而不是抛 KeyError(它实现了 missing 钩子);第二,most_common 直接给出按次数降序的 TOP 列表(指定 n 时内部用堆,复杂度 O(n log k),n 是总键数、k 是要求的条数)。它还能做运算:加号是逐键相加,减号是逐键相减——但减法的结果只保留正数,这是刻意设计:计数语义里「负数」通常是 bug,直接丢掉比让程序带着负数跑下去更安全。想保留负计数,用 subtract 方法。
defaultdict:读的时候才产生默认值
defaultdict 也是 dict 的子类。普通字典访问缺失键抛 KeyError;defaultdict 在缺失时调用构造时传入的「工厂函数」(factory)生成默认值并插入字典,然后返回它。工厂是零参调用:int() 得 0、list() 得空列表、dict() 得空字典、set() 得空集合,甚至能传 lambda 自定义。与 dict 的两个原生写法对比:d.get(k, 默认) 只读不写,需要配合赋值才能计数;d.setdefault(k, 默认) 一步到位,但默认值参数每次调用都会先求值。defaultdict 的代价是副作用:单纯「读一下」缺失键也会让字典变大。
deque:两端都是 O(1) 的队列
deque(double-ended queue,双端队列)是独立的序列容器。list 是连续内存上的动态数组:末尾 append/pop 是 O(1),但头部插入 insert(0, x) 要把后面所有元素整体后移,O(n)。deque 内部用分块链表组织内存,两端进出都是摊销 O(1)。它还支持 maxlen 构造参数:超过长度时自动从另一端挤掉最旧元素,天然就是「保留最近 N 条」的环形缓冲。
OrderedDict 的现状与 ChainMap 的定位
先说 OrderedDict:Python 3.7 起字典保持插入顺序成为语言保证(CPython 3.6 已是实现细节),「dict 无序」成了历史。OrderedDict 因此大幅退居二线,剩下的独有价值是:相等性比较时连顺序一起比、move_to_end 调整键序、popitem(last=False) 从头部弹出。绝大多数场景直接用 dict。
ChainMap 把多个映射「链」在一起做查找:查询从前到后,第一个命中的映射生效;写操作永远落在第一个映射。典型场景是配置叠加——默认配置、环境变量、命令行参数按优先级链起来,不用手工合并字典。
操作与实现
namedtuple:定义、访问与「修改」
先用最小例子感受字段名访问、下标兼容与不可变:
from collections import namedtuple
Point = namedtuple('Point', ['x', 'y'])
p = Point(3, 4)
print(p.x, p.y) # 字段名访问
print(p[0], p[1]) # 下标访问依旧成立
x, y = p # 解包依旧成立
print(x, y)
try:
p.x = 9 # 元组不可变:赋值直接报错
except AttributeError as e:
print('AttributeError:', e)
3 4
3 4
3 4
AttributeError: can't set attribute
「修改」记录用 _replace,它返回新对象、原对象不动;_asdict 转普通字典,_fields 给出字段名元组:
from collections import namedtuple
Student = namedtuple('Student', ['name', 'score'])
s = Student('小明', 92)
print(s._asdict()) # 转成普通字典
s2 = s._replace(score=95) # 生成“修改后”的新对象
print(s, '->', s2)
print(Student._fields) # 字段名元组
{'name': '小明', 'score': 92}
Student(name='小明', score=92) -> Student(name='小明', score=95)
('name', 'score')
字段可以指定默认值,构造时省略的字段自动补齐:
from collections import namedtuple
Point = namedtuple('Point', ['x', 'y'], defaults=[0])
print(Point(1)) # y 省略时用默认值
print(Point(x=2, y=3))
Point(x=1, y=0)
Point(x=2, y=3)
注意:下划线开头的 _replace、_asdict、_fields 不是私有方法,这是 namedtuple 的公开接口;真正报错的只有对字段赋值。
Counter:一行完成计数
把词列表丢给 Counter,统计当场完成;缺失键返回 0;most_common 取 TOP;update 追加累加:
from collections import Counter
words = ['the', 'quick', 'brown', 'the', 'fox', 'the']
c = Counter(words)
print(c) # 自动统计好次数
print(c['the'], c['zebra']) # 缺失键返回 0,不报 KeyError
print(c.most_common(2)) # 次数最高的前 2 个
c.update(['the', 'jump']) # 追加一批词,计数累加
print(c['the'])
Counter({'the': 3, 'quick': 1, 'brown': 1, 'fox': 1})
3 0
[('the', 3), ('quick', 1)]
4
Counter 之间的算术运算,加号并集式累加、减号只保留正数、& 与 | 分别是取较小值与较大值:
from collections import Counter
c1 = Counter(a=3, b=1)
c2 = Counter(a=1, b=2, c=4)
print(c1 + c2) # 对应键相加
print(c1 - c2) # 相减,只保留正数
print(c1 & c2) # 交集:取较小值
print(c1 | c2) # 并集:取较大值
Counter({'a': 4, 'c': 4, 'b': 3})
Counter({'a': 2})
Counter({'a': 1, 'b': 1})
Counter({'c': 4, 'a': 3, 'b': 2})
elements 把计数展开成迭代器(每个键重复出现其计数次,忽略小于 1 的计数);需要出现负数的场合用 subtract 而不是 -:
from collections import Counter
c = Counter('abracadabra')
print(sorted(c.elements())) # elements() 把计数展开成迭代器
c1 = Counter(a=3, b=1)
c1.subtract(Counter(a=1, b=2, c=4)) # 允许出现负数计数
print(c1)
['a', 'a', 'a', 'a', 'a', 'b', 'b', 'c', 'd', 'r', 'r']
Counter({'a': 2, 'b': -1, 'c': -4})
defaultdict:三种最常用的缺省工厂
按学生姓名聚合成绩,缺省工厂用 list;统计字符出现次数,缺省工厂用 int:
from collections import defaultdict
scores = [('小明', 85), ('小红', 92), ('小明', 78),
('小刚', 66), ('小红', 88)]
group = defaultdict(list) # 缺省工厂:空列表
for name, score in scores:
group[name].append(score)
print(dict(group))
d = defaultdict(int) # 缺省工厂:0
for ch in 'banana':
d[ch] += 1
print(d)
print(d['a'])
{'小明': [85, 78], '小红': [92, 88], '小刚': [66]}
defaultdict(<class 'int'>, {'b': 1, 'a': 3, 'n': 2})
3
两层嵌套的结构(用户 -> 属性 -> 值)用 dict 做工厂,访问任意层都不用手动初始化:
from collections import defaultdict
d = defaultdict(dict) # 缺省工厂:空字典
d['user1']['age'] = 20
d['user1']['city'] = '北京'
print(d)
defaultdict(<class 'dict'>, {'user1': {'age': 20, 'city': '北京'}})
与「手写」计数写法对比,三种方案殊途同归,但表达成本不同:
words = ['apple', 'banana', 'apple', 'cherry', 'banana', 'apple']
count1 = {}
for w in words:
count1[w] = count1.get(w, 0) + 1
count2 = {}
for w in words:
count2[w] = count2.setdefault(w, 0) + 1
from collections import defaultdict
count3 = defaultdict(int)
for w in words:
count3[w] += 1
print(count1)
print(count2)
print(dict(count3))
{'apple': 3, 'banana': 2, 'cherry': 1}
{'apple': 3, 'banana': 2, 'cherry': 1}
{'apple': 3, 'banana': 2, 'cherry': 1}
三种方案结果完全一致:get 方案要「读 + 写」两步,setdefault 方案要记住默认值参数,defaultdict 方案则把「缺省值从哪来」的细节收进容器本身。注意 setdefault 有个隐藏的共享引用坑:若先建好一个默认对象再传进去,所有缺省键会共享同一个对象(实测 shared = []; b.setdefault('x', shared).append(1); b.setdefault('y', shared).append(2) 得到 {'x': [1, 2], 'y': [1, 2]}),而 defaultdict 的工厂每次访问都会新建,天然没有这个坑。
deque:双端队列与环形缓冲
两端进出、popleft 出队、maxlen 自动淘汰最旧元素:
from collections import deque
q = deque(['a', 'b'])
q.append('c') # 右端入队
q.appendleft('z') # 左端入队
print(q)
print(q.popleft()) # 左端出队
print(q.pop()) # 右端出队
print(q)
deque(['z', 'a', 'b', 'c'])
z
c
deque(['a', 'b'])
maxlen 版本就是「最近 N 条」的缓存:写满后每追加一条就挤掉最旧的一条:
from collections import deque
buf = deque(maxlen=3) # 环形缓冲:最多留 3 条
for i in range(1, 7):
buf.append(i) # 满了自动挤掉最旧的一条
print(buf)
deque([4, 5, 6], maxlen=3)
「为什么不用 list」用数字说话——本机(Python 3.12.10,Windows)实测:
import time
from collections import deque
N = 100000
t0 = time.perf_counter()
lst = []
for i in range(N):
lst.insert(0, i)
t1 = time.perf_counter()
print(f'list.insert(0) {N} 次: {(t1 - t0):.3f} 秒')
t0 = time.perf_counter()
dq = deque()
for i in range(N):
dq.appendleft(i)
t1 = time.perf_counter()
print(f'deque.appendleft {N} 次: {(t1 - t0):.3f} 秒')
list.insert(0) 100000 次: 3.098 秒
deque.appendleft 100000 次: 0.006 秒
十万次的规模就差了约五百倍:list 每次头部插入都要把整个数组后移一位(O(n)),deque 不用动别的元素。若你的场景是「只在末尾追加」或「需要按下标随机访问」,list 仍然更合适——deque 按下标访问是 O(n),别拿它当数组用。
OrderedDict:3.7 之后还剩什么
实测普通 dict 与 OrderedDict 的相等性差异:
from collections import OrderedDict
d1 = {'a': 1, 'b': 2, 'c': 3}
d2 = {'c': 3, 'b': 2, 'a': 1}
print(d1 == d2) # 普通 dict:内容相同即相等
od1 = OrderedDict(d1)
od2 = OrderedDict(d2)
print(od1 == od2) # OrderedDict:顺序不同即不等
print(od1 == d1) # 与普通 dict 比较时又忽略顺序
od = OrderedDict([('k', 1)])
od['k2'] = 2
od.move_to_end('k') # 把 k 挪到末尾
print(list(od))
print(od.popitem(last=True))
True
False
True
['k2', 'k']
('k', 1)
结论:3.7+ 的日常写法直接用 dict;只有「必须按顺序判等」「要把某个键挪到队尾」「从头弹出」这类需求才轮到 OrderedDict。记住一个不对称行为:OrderedDict 和普通 dict 比较时反而忽略顺序(od1 == d1 为 True),只有两个 OrderedDict 互相比较才看顺序。
ChainMap:多映射按优先级查找
配置文件叠加是 ChainMap 的主场:查询从前到后第一个命中,写操作落在第一个映射:
from collections import ChainMap
defaults = {'color': 'gray', 'font': 'mono'}
override = {'color': 'black'}
cm = ChainMap(override, defaults) # 优先级从前到后
print(cm['color']) # 第一个映射先命中
print(cm['font']) # 第一个没有,落到底层
cm['size'] = 12 # 写操作只落进第一个映射
print(override)
print(defaults)
print(dict(ChainMap(override, defaults)))
black
mono
{'color': 'black', 'size': 12}
{'color': 'gray', 'font': 'mono'}
{'color': 'black', 'font': 'mono', 'size': 12}
它不复制任何映射,只是把几个字典「看成一个」;代价是每次查找要多走一层判断,且链越长查找越慢。只有一层「默认 + 覆盖」需求时也完全可以手动合并,ChainMap 的价值在于任意多层的清晰分层。
易错点与陷阱
1. defaultdict 的「读」也会写
这是 defaultdict 最反直觉的行为:d['missing'] 只是访问,就为字典新增了一个键(实测:索引缺失键后键存在且值为 0)。若在遍历字典的同时用 d[k] 读键,可能边遍历边长,甚至引发 RuntimeError。只想「查一下」时用 d.get(k)(实测返回 None 且不插入)或 in 判断;想清楚这里确实是「要写默认值」再直接索引。
2. Counter 缺失键返回 0,但不会记住
Counter 对缺失键返回 0 而不报错,容易让人误以为「读了一次就有了记录」。实测 c['zebra'] 返回 0 后,‘zebra’ 并不在 Counter 里。想计数必须显式 update 或索引赋值。与 defaultdict 对照着记:Counter 的缺失是「只读不写」,defaultdict 的缺失是「读即是写」。
3. namedtuple 表面可改,实际不可变
新手常写 p.x = 9 期望能改,得到 AttributeError。正确的「改」是 p._replace(x=9)——但它返回新对象,原来的 p 纹丝不动(实测)。另一个误会是把 _replace、_asdict 当成私有方法不敢用:带下划线是历史约定,它们是官方公开 API,放心用。
4. Counter 的减法丢掉非正数
c1 - c2 对结果为 0 或负数的键直接弃置(实测只剩恰好为正的键),这不是 bug 而是防错设计:计数出现负数多半是逻辑错误,静默丢掉比让脏数据传下去好。如果确实需要负数(比如「还差多少」的实时统计),用 subtract 方法。
5. deque(maxlen) 的静默淘汰与 list 头部插入的性能陷阱
maxlen 写满后,新元素会把最旧元素悄悄挤掉——做「操作日志」「消息缓存」时这是特性,做「必须不丢数据」的队列时这是灾难,入队前先判断长度。另一面是性能:头部频繁插入时无脑用 list 会慢两个数量级以上(本机实测十万次 insert(0) 约 3.1 秒,deque.appendleft 约 6 毫秒)。
小结
collections 模块是内建容器的工程化补丁包:namedtuple 给元组起名字,保持不可变、可哈希、可解包的元组本色,_replace/_asdict/_fields 是它的公开 API;Counter 一行统计,缺失键返回 0,加号累积、减号只留正数,需要负数改用 subtract;defaultdict 把「缺省值」收进容器,int/list/dict 三种工厂覆盖计数、分组、嵌套三大场景,代价是读即写;deque 左右两端都是 O(1),maxlen 即环形缓冲。OrderedDict 在 dict 保序的今天只剩顺序判等与挪位弹出等少数用途,ChainMap 负责多映射的优先级查找。下一篇,我们用这四件套完成一个真实的词频统计项目。
练习与思考题
- 用 defaultdict(list) 把
['apple', 'banana', 'apricot', 'cherry', 'avocado', 'blueberry']按首字母分组,程序输出应为{'a': ['apple', 'apricot', 'avocado'], 'b': ['banana', 'blueberry'], 'c': ['cherry']}。 - 用 deque(maxlen=5) 模拟「最近 5 条搜索记录」,连续 append 8 条记录(‘s1’ 到 ‘s8’),打印结果应为
deque(['s4', 's5', 's6', 's7', 's8'], maxlen=5),解释前三条去哪了。 - 用 namedtuple 定义
Book = namedtuple('Book', ['title', 'author', 'price']),创建一本 59 元的书,用 _replace 把价格改为 8 折后打印,验证原对象 price 仍是 59。 - 思考题:结合 list 的连续内存布局,解释为什么 insert(0, x) 是 O(n) 而 appendleft 是 O(1);再想想 Counter 的减号「只保留正数」在什么场景会坑到你(提示:对账、差额统计)。