Python collections 包详解
Python 内置的 dict、list、set、tuple 已经覆盖了绝大多数日常需求,但一旦遇到"按出现次数统计"“给元组字段起名字”“双端高效增删”"层叠的配置查找"这类场景,手写往往既啰嗦又容易出错。标准库的 collections 模块正是为此而生:它在内置类型之上提供了一批专门化的容器,既保留 Pythonic 的简洁,又补齐了数据结构层面的缺口。
collections 不是第三方库,不需要安装,import collections 即可。它包含两类东西:具体容器类(namedtuple、deque、ChainMap、Counter、OrderedDict、defaultdict、UserDict 等)和抽象基类(collections.abc,定义容器协议)。本文按"高频到进阶"的顺序逐一拆解,重点讲清楚每个工具解决了什么问题、内部如何实现、性能边界在哪里、什么时候不该用。
namedtuple:带字段名的不可变记录
普通元组 (x, y) 通过下标访问,point[0]、point[1]——一旦字段多了,没人记得 data[3] 到底是年龄还是工号。namedtuple 给元组的每个位置起一个名字,既保留了元组的不可变性和轻量,又获得了自文档化的可读性。
基本用法
1 | |
字段名可以是空格/逗号分隔的字符串,也可以是可迭代对象:
1 | |
字段名必须合法(不能以下划线开头,不能是 Python 关键字)。若确实需要用关键字作字段名,用 rename=True 把非法名自动改成 _0、_1:
1 | |
四个以单下划线开头的"协议方法"
namedtuple 生成的类自带四个特殊方法:
1 | |
_replace 是 namedtuple 不可变风格的精髓——更新即复制:
1 | |
默认值与可选字段
Python 3.7 起 namedtuple 支持 defaults 参数,从右往左填充默认值:
1 | |
defaults 长度可以小于字段数,缺省的字段必须放在前面。这个"从右往左"的规则与函数默认参数一致。
内部实现与内存
namedtuple 本质是用 exec 动态生成一个继承自 tuple 的类,字段被实现为 property。它没有 __dict__,因此实例内存占用与普通 tuple 几乎相同,远低于普通对象:
1 | |
正因如此,当需要存储大量结构化记录(如读取 CSV、解析日志)时,namedtuple 比 dict 或自定义类更省内存。
与 dataclass 的抉择
Python 3.7 引入的 dataclass 在很多场景下是更现代的选择:
1 | |
两者的取舍:
| 维度 | namedtuple | dataclass |
|---|---|---|
| 可变性 | 不可变 | 默认可变(frozen=True 可冻结) |
| 内存 | 极小(无 __dict__) | 较大(默认有 __dict__) |
| 继承 tuple | 是,可下标访问、可解包 | 否 |
| 类型标注 | 弱(字段名是字符串) | 强(原生支持) |
| 方法定义 | 不便 | 方便(就是普通类) |
经验法则:需要不可变、轻量、可哈希、能当元组用的记录 → namedtuple;需要可变、带方法、强类型的领域对象 → dataclass。
陷阱
1 | |
字段名不能重复。另外 namedtuple 生成的类是全局的,不要在循环或函数里反复定义同名类型,会污染命名空间且无法被垃圾回收(namedtuple 会缓存类对象)。
deque:双端队列
list 在尾部 append/pop 是 O(1),但在头部 insert(0, x) / pop(0) 是 O(n)——所有元素都要挪位。当你需要一个两端都能高效增删的队列时,用 deque。
基本用法
1 | |
append、appendleft、pop、popleft 全部是 O(1)。相比之下 list.pop(0) 是 O(n):
1 | |
maxlen:固定长度的滑动窗口
deque 接受一个 maxlen 参数,一旦设定,队列长度满了之后再追加就会从另一端自动挤掉元素:
1 | |
这是实现"最近 N 项"“滚动平均值”"日志尾部缓冲"的惯用法,比手动维护 list 简洁得多。注意 maxlen 一旦设定不可更改。
rotate:旋转
rotate(n) 把队列元素整体向右旋转 n 步(n 为负则向左),超出部分回绕到另一端,O(k):
1 | |
一个经典应用是约瑟夫环(报数出列):
1 | |
内部实现
deque 底层是双向链表,按块(block)分配:每个块存 64 个 PyObject 指针,块之间用指针连接。这种分块设计避免了纯链表每个元素一个节点的内存爆炸,同时保留了双端 O(1) 增删的特性。随机访问 d[i] 是 O(n)(要遍历块),所以 deque 不适合按下标随机读写——那是 list 的主场。
线程安全
deque 的 append、appendleft、pop、popleft 是原子操作,可在多线程下安全用作生产者-消费者队列的基础:
1 | |
不过生产环境通常直接用 queue.Queue(带阻塞和通知机制)或 asyncio.Queue,deque 适合无锁的轻量场景。
应用:BFS 广度优先搜索
deque 是图/树 BFS 的标准容器:
1 | |
用 list 配 pop(0) 会让 BFS 从 O(V+E) 退化成 O(V²),这是面试和竞赛中常见的隐性性能坑。
Counter:计数专家
统计元素出现次数是高频需求。手写循环 + dict 没什么难度,但 Counter 把这件事以及围绕它的常见操作(取 top N、合并、做集合运算)封装得非常顺手。
基本用法
1 | |
“缺失键返回 0"是 Counter 与普通 dict 最直观的区别——这正是计数的语义:没出现就是 0 次。这也意味着你不能靠 if key in counter 之外的方式判断"真有零个"和"没出现过”,但实践中无所谓。
1 | |
most_common:取前 N
1 | |
底层用 heapq.nlargest 实现,取前 N 时是 O(n log k),比排序整个字典 O(n log n) 更优(当 N 远小于 n 时)。
elements:按计数展开
elements() 返回一个迭代器,把每个元素按其计数重复产出:
1 | |
注意顺序是按元素首次插入顺序,计数 ≤ 0 的元素会被忽略。
total:求和(Python 3.10+)
1 | |
3.10 之前需要 sum(c.values())。
算术与集合运算
Counter 重载了 +、-、&、|,让"合并计数""取交集"变得优雅:
1 | |
减法 c1 - c2 的语义值得注意:b 变成 1-2 = -1 ≤ 0,会被丢弃,所以结果里没有 b。如果你想保留零和负数(比如做"余额"追踪),需要用 + 配合负值或手动处理。
实战——统计两段文本合并后的词频:
1 | |
陷阱:Counter 不是普通 dict
1 | |
Counter 允许零或负的计数(常用于临时扣减),但 elements()、most_common() 等会忽略它们。把它当通用 dict 用时要注意 c['x'] 返回 0 会"凭空创建"键——其实不会,访问不存在的键返回 0 但不插入该键,这是 Counter 特意覆写的 __missing__。
defaultdict:告别 KeyError
写分组逻辑时,下面这种模板代码无处不在:
1 | |
defaultdict 把"键不存在时自动初始化"这件事自动化了:
1 | |
工厂函数的选择
defaultdict 的参数是一个无参可调用对象(工厂函数),键缺失时调用它生成默认值:
1 | |
defaultdict(int) 是经典的计数惯用法(虽然 Counter 更专业):
1 | |
嵌套 defaultdict
需要多层嵌套字典时,defaultdict 可以递归构造:
1 | |
tree = lambda: defaultdict(tree) 这种自引用写法能构造任意深度的嵌套字典,访问 data['a']['b']['c']['d'] 不会报 KeyError,会逐层自动创建。注意序列化前要递归转成普通 dict。
与 dict.setdefault 对比
1 | |
setdefault 也能做到,但有微妙差异:defaultdict 只在 __getitem__(d[k])触发缺失时调用工厂;setdefault 是显式调用。更重要的是性能——defaultdict(factory) 的工厂调用发生在 C 层,而 setdefault 每次都会构造默认值参数(即使键已存在,参数也会被求值):
1 | |
对热点循环,defaultdict 通常更快。
陷阱:意外的键创建
1 | |
用 d[k] 访问会触发默认值创建,即使你只是查询。若想避免副作用,用 k in d 或 d.get(k)(get 不会触发工厂)。这个坑在调试时会让你困惑"我明明没插入这个键,它怎么冒出来的"。
另一个坑:defaultdict 传给期望普通 dict 的函数通常没问题(它就是 dict 子类),但有些库用 type(x) is dict 做严格检查会失败,需要 dict(d) 转换。
OrderedDict:有序字典
历史背景与"为什么 3.7+ 还需要它"
Python 3.7 之前,dict 的迭代顺序是不保证的(CPython 实现上是哈希值顺序,看似有序实则依赖插入和哈希随机化)。那时候要让字典保持插入顺序,只能用 OrderedDict。
Python 3.7 起,普通 dict 被规范为保证插入有序,于是很多人说"OrderedDict 没用了"。这是误解。OrderedDict 提供了普通 dict 没有的两个关键能力:
1 | |
move_to_end 和 popitem(last=...) 是 OrderedDict 独有的,普通 dict 做不到 O(1) 地把某个键挪到头尾。此外 OrderedDict 的相等比较考虑顺序:
1 | |
需要"顺序敏感的相等"时,OrderedDict 仍是正确选择。
经典应用:LRU 缓存
OrderedDict 是手写 LRU(Least Recently Used)缓存的利器——move_to_end 标记"最近使用",popitem(last=False) 淘汰"最久未用":
1 | |
get 和 put 都是 O(1)。标准库的 functools.lru_cache 装饰器底层正是用类似机制实现的(C 加速版)。
FIFO 队列式字典
需要"先进先出"地管理带值的键时,popitem(last=False) 直接给你 O(1) 出队:
1 | |
内部实现
OrderedDict 维护一个双向链表 + 哈希表:哈希表存键值,链表记录顺序。这使得 move_to_end 和 popitem 都是 O(1),但内存开销大于普通 dict。普通 dict 在 3.7+ 也有序,但它内部是"紧凑数组"结构,没有独立的链表节点,因此更省内存——这也是为什么除非需要那两个特殊方法,否则优先用普通 dict。
ChainMap:层叠的字典视图
多个字典需要按优先级查找时,传统做法是 merged = {**defaults, **overrides}——但这是拷贝合并,会创建新字典,且无法反映后续的原字典修改。ChainMap 提供了一种零拷贝的视图:多个字典串成一条链,查找时从前往后找,第一个命中即返回。
基本用法
1 | |
ChainMap 不复制数据,它持有底层字典的引用,因此底层字典改动会实时反映:
1 | |
查找顺序与所有键
1 | |
maps 是可变的,你可以直接操作它来增删层:
1 | |
写操作只影响第一层
这是 ChainMap 最需要理解的语义:写操作(赋值、删除)永远作用在第一个 map 上:
1 | |
del 一个"只存在于更深层"的键会抛 KeyError——因为删除也只看第一层。这种"读穿多层、写只入首层"的行为非常适合"配置覆盖"场景:读取时按优先级,写入时只改最高优先级(如命令行参数),不动默认值。
new_child 与 parents
1 | |
new_child 返回一个新的 ChainMap(不修改原链),在最前插入一层;parents 返回去掉第一层后的视图。这让"作用域栈"的模拟很自然——每进入一层作用域 new_child,退出时用 parents。
应用:层叠配置 / 作用域
1 | |
与 {**a, **b, **c} 合并的区别:合并是快照(一次性拷贝,之后各字典互不影响);ChainMap 是实时视图(底层变化即反映),且省内存。需要不可变快照时用合并,需要动态层叠时用 ChainMap。
UserDict / UserList / UserString:继承友好的包装
为什么不直接继承 dict
很多教程教人"自定义字典"时直接 class MyDict(dict),这其实是个坑:
1 | |
dict 的方法是用 C 实现的,update、__init__ 等并不会调用你覆写的 __setitem__/__getitem__,导致行为不一致。要让所有路径都走你的逻辑,需要覆写大量方法,极易遗漏。
UserDict 解决了这个问题:它把数据存在一个内部 dict 属性 data 里,所有方法都通过 data 间接操作,因此你覆写 __setitem__ 等基础方法后,update 等高级方法也会正确调用它们:
1 | |
UserList 与 UserString
同理,UserList 把数据存在 self.data(一个 list),UserString 存在 self.data(一个 str)。它们存在的核心动机一致:当你需要继承内置类型并覆写其行为时,避免 C 实现绕过覆写方法的陷阱。
1 | |
何时用
现代 Python 中,如果你只是想"包装一个容器加点行为",UserDict/UserList 仍是推荐做法。但要注意:UserDict 不是 dict 的子类(它是 MutableMapping 的子类,内含一个 dict),因此 isinstance(UserDict 实例, dict) 是 False——这在需要"看起来像 dict 但行为受控"时反而是优点(避免被误当 dict 直接传给 C 扩展)。UserList 则是 MutableSequence,isinstance(x, list) 同样为 False。
如果只是给数据加方法而不改容器语义,优先考虑 dataclass;只有在确实需要自定义容器的存取行为时,才用 UserDict 系列。
collections.abc:容器协议与抽象基类
collections.abc(注意是子模块,必须 import collections.abc 或 from collections.abc import ...)定义了 Python 容器的协议(protocol)。它回答两个问题:这个对象支持什么操作?我自定义的容器要实现哪些方法才算"合格"?
常见抽象基类
| ABC | 需实现的方法 | 额外混入的方法 | 语义 |
|---|---|---|---|
Container | __contains__ | — | 支持 in |
Iterable | __iter__ | — | 支持 for、解包 |
Iterator | __next__, __iter__ | — | 迭代器 |
Sized | __len__ | — | 支持 len() |
Sequence | __getitem__, __len__ | __contains__, __iter__, __reversed__, index, count | 序列 |
MutableSequence | + __setitem__, __delitem__, insert | + append, reverse, extend, pop, remove, __iadd__ | 可变序列 |
Mapping | __getitem__, __len__, __iter__ | __contains__, keys, values, items, get, __eq__, __ne__ | 映射 |
MutableMapping | + __setitem__, __delitem__ | + pop, popitem, clear, update, setdefault | 可变映射 |
Set / MutableSet | 类似上面 | __le__, __or__, & 等 | 集合 |
isinstance 检查:鸭子类型的补充
1 | |
用 isinstance(x, Mapping) 而非 isinstance(x, dict) 更宽松——它接受任何实现了映射协议的对象(UserDict、ChainMap、各种 ORM 对象等),符合鸭子类型精神。同理 isinstance(x, Sequence) 比 isinstance(x, (list, tuple)) 更通用。
一个实用场景:判断参数是"字典类"还是"列表类":
1 | |
注意 str 和 bytes 也是 Sequence,处理嵌套结构时要单独排除,否则会把字符串拆成字符。
继承 ABC 获得混入方法
实现自定义容器时,继承对应 ABC 并实现少数必需方法,就能免费获得大量派生方法:
1 | |
只需实现 __len__ 和 __getitem__,in、index、count、__iter__、__reversed__ 全部自动可用。这正是 ABC 的价值——用最少的方法实现换来完整的协议支持。
注册虚拟子类
ABC 支持"虚拟子类"注册:让一个没有继承关系的类被 isinstance 认为是某 ABC 的实例:
1 | |
这用于"声明式"地标注某个类符合某协议,而不强制继承。标准库中 builtins 的许多类型就是被注册为各种 ABC 的虚拟子类(如 dict 注册为 MutableMapping 的虚拟子类)。
历史坑:collections 中的旧别名
Python 3.3 起,collections 中的抽象基类被移到了 collections.abc,但保留了向后兼容的别名(collections.Mapping 等)。3.10 起这些别名被移除,必须从 collections.abc 导入:
1 | |
如果你的代码还在用 from collections import Mapping,升级到 3.10+ 会直接报错,改为 collections.abc 即可。
性能与选型总结
理解每个工具的复杂度,才能在正确场景选出正确工具。
| 操作 | list | deque | set/dict | Counter |
|---|---|---|---|---|
| 头部增删 | O(n) | O(1) | — | — |
| 尾部增删 | O(1) | O(1) | — | — |
随机访问 x[i] | O(1) | O(n) | — | — |
成员测试 in | O(n) | O(n) | O(1) | O(1) |
| 计数统计 | 需手写 | — | — | 原生支持 |
选型决策树:
- 要计数、取 top N、做频率运算 →
Counter - 要分组、自动初始化嵌套结构 →
defaultdict - 要双端高效增删 / 滑动窗口 / BFS →
deque - 要不可变、轻量、可哈希的记录 →
namedtuple(或dataclass(frozen=True)) - 要 LRU / 顺序敏感的相等 / move_to_end →
OrderedDict - 要层叠的只读视图、零拷贝配置查找 →
ChainMap - 要自定义容器存取行为 →
UserDict/UserList - 要检查/实现容器协议 →
collections.abc - 以上都不需要 → 普通
dict/list/set就够了
一个常被忽视的原则:优先用内置类型,只在它们力不从心时才上 collections。collections 的工具都是为特定场景优化的,在通用场景下并不一定比 dict/list 快(如 OrderedDict 内存大于 dict,deque 随机访问慢于 list)。选型的核心是匹配数据结构的特性与操作模式,而不是"高级"。
总结
collections 是 Python 标准库里被低估的宝藏。它不引入新概念,只是把日常代码里反复出现的模式——分组、计数、队列、层叠配置——封装成经过优化的现成工具。掌握它的回报是双重的:代码更短、更快,也更容易读;同时你会更深刻地理解 Python 容器协议的设计。
回头看本文的脉络:namedtuple 给记录命名,deque 让两端都高效,Counter 把计数变成一行,defaultdict 消灭模板代码,OrderedDict 提供顺序操作,ChainMap 层叠视图,UserDict 让继承可控,collections.abc 定义协议。它们各自解决一个具体问题,组合起来几乎覆盖了所有"内置类型不够用"的场景。
记住:工具的价值在于被用在正确的地方。collections 里的每个类都标定了自己的适用边界——理解边界比记住 API 更重要。当你下次再写 if key not in d: d[key] = [] 时,停下来想想 defaultdict;当你 list.pop(0) 在循环里时,换成 deque。这些微小的选择,累积起来就是代码质量的差距。

