Python 内置的 dictlistsettuple 已经覆盖了绝大多数日常需求,但一旦遇到"按出现次数统计"“给元组字段起名字”“双端高效增删”"层叠的配置查找"这类场景,手写往往既啰嗦又容易出错。标准库的 collections 模块正是为此而生:它在内置类型之上提供了一批专门化的容器,既保留 Pythonic 的简洁,又补齐了数据结构层面的缺口。

collections 不是第三方库,不需要安装,import collections 即可。它包含两类东西:具体容器类namedtupledequeChainMapCounterOrderedDictdefaultdictUserDict 等)和抽象基类collections.abc,定义容器协议)。本文按"高频到进阶"的顺序逐一拆解,重点讲清楚每个工具解决了什么问题、内部如何实现、性能边界在哪里、什么时候不该用

namedtuple:带字段名的不可变记录

普通元组 (x, y) 通过下标访问,point[0]point[1]——一旦字段多了,没人记得 data[3] 到底是年龄还是工号。namedtuple 给元组的每个位置起一个名字,既保留了元组的不可变性和轻量,又获得了自文档化的可读性。

基本用法

1
2
3
4
5
6
7
8
9
from collections import namedtuple

# 定义一个具名元组类型,本质是创建一个 tuple 的子类
Point = namedtuple('Point', ['x', 'y'])

p = Point(11, y=22) # 位置参数与关键字参数可混用
print(p.x, p.y) # 11 22 —— 字段名访问,比 p[0] p[1] 清晰
print(p[0], p[1]) # 11 22 —— 依然兼容下标访问
print(p) # Point(x=11, y=22) —— repr 自带字段名

字段名可以是空格/逗号分隔的字符串,也可以是可迭代对象:

1
2
3
Point = namedtuple('Point', 'x y')              # 空格分隔
Color = namedtuple('Color', 'red green blue') # 三个字段
Point = namedtuple('Point', 'x, y, z') # 逗号分隔

字段名必须合法(不能以下划线开头,不能是 Python 关键字)。若确实需要用关键字作字段名,用 rename=True 把非法名自动改成 _0_1

1
2
Record = namedtuple('Record', ['name', 'class', 'def'], rename=True)
# 等价于 ['name', '_1', '_2'],'class' 和 'def' 是关键字

四个以单下划线开头的"协议方法"

namedtuple 生成的类自带四个特殊方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Point = namedtuple('Point', ['x', 'y'])
p = Point(11, 22)

# _fields:字段名元组,元编程时常用
Point._fields # ('x', 'y')

# _asdict():转成 OrderedDict(3.8+ 返回普通 dict),方便序列化为 JSON
p._asdict() # {'x': 11, 'y': 22}

# _replace():返回一个修改了某些字段的"副本"(因为不可变,只能新建)
p._replace(x=100) # Point(x=100, y=22)

# _make():从已有可迭代对象构造,等价于 Point(*iterable)
data = [33, 44]
Point._make(data) # Point(x=33, y=44)

_replacenamedtuple 不可变风格的精髓——更新即复制:

1
2
3
4
5
6
# 不可变记录的"更新"链式调用
from collections import namedtuple
User = namedtuple('User', 'name age email')
u = User('alice', 30, 'a@x.com')
u = u._replace(age=31)._replace(email='alice@x.com')
# User(name='alice', age=31, email='alice@x.com')

默认值与可选字段

Python 3.7 起 namedtuple 支持 defaults 参数,从右往左填充默认值:

1
2
3
4
Point = namedtuple('Point', ['x', 'y', 'z'], defaults=[0, 0])
Point(1) # Point(x=1, y=0, z=0) —— z 默认 0,y 默认 0
Point(1, 2) # Point(x=1, y=2, z=0)
Point(1, 2, 3) # Point(x=1, y=2, z=3)

defaults 长度可以小于字段数,缺省的字段必须放在前面。这个"从右往左"的规则与函数默认参数一致。

内部实现与内存

namedtuple 本质是用 exec 动态生成一个继承自 tuple 的类,字段被实现为 property。它没有 __dict__,因此实例内存占用与普通 tuple 几乎相同,远低于普通对象:

1
2
3
4
5
6
import sys
from collections import namedtuple

Point = namedtuple('Point', ['x', 'y'])
print(sys.getsizeof(Point(1, 2))) # 56 字节左右(含 tuple 头部)
print(sys.getsizeof({'x': 1, 'y': 2})) # 明显更大,dict 有哈希表开销

正因如此,当需要存储大量结构化记录(如读取 CSV、解析日志)时,namedtupledict 或自定义类更省内存。

与 dataclass 的抉择

Python 3.7 引入的 dataclass 在很多场景下是更现代的选择:

1
2
3
4
5
6
from dataclasses import dataclass

@dataclass
class Point:
x: float
y: float = 0.0

两者的取舍:

维度namedtupledataclass
可变性不可变默认可变(frozen=True 可冻结)
内存极小(无 __dict__较大(默认有 __dict__
继承 tuple是,可下标访问、可解包
类型标注弱(字段名是字符串)强(原生支持)
方法定义不便方便(就是普通类)

经验法则:需要不可变、轻量、可哈希、能当元组用的记录namedtuple需要可变、带方法、强类型的领域对象dataclass

陷阱

1
Point = namedtuple('Point', ['x', 'y', 'x'])  # ValueError: 重复字段名

字段名不能重复。另外 namedtuple 生成的类是全局的,不要在循环或函数里反复定义同名类型,会污染命名空间且无法被垃圾回收(namedtuple 会缓存类对象)。

deque:双端队列

list 在尾部 append/pop 是 O(1),但在头部 insert(0, x) / pop(0) 是 O(n)——所有元素都要挪位。当你需要一个两端都能高效增删的队列时,用 deque

基本用法

1
2
3
4
5
6
7
from collections import deque

dq = deque([1, 2, 3])
dq.append(4) # 右端追加: deque([1, 2, 3, 4])
dq.appendleft(0) # 左端追加: deque([0, 1, 2, 3, 4])
dq.pop() # 4 —— 右端弹出
dq.popleft() # 0 —— 左端弹出

appendappendleftpoppopleft 全部是 O(1)。相比之下 list.pop(0) 是 O(n):

1
2
3
4
5
6
7
8
import timeit
from collections import deque

# list 头部弹出:每次都要整体前移,O(n)
timeit.timeit('l.pop(0)', setup='l=list(range(100000))', number=10000)
# deque 头部弹出:O(1)
timeit.timeit('d.popleft()', setup='from collections import deque; d=deque(range(100000))', number=10000)
# 同样 10000 次操作,list 可能慢几十倍

maxlen:固定长度的滑动窗口

deque 接受一个 maxlen 参数,一旦设定,队列长度满了之后再追加就会从另一端自动挤掉元素:

1
2
3
4
5
6
7
8
9
10
11
12
from collections import deque

# 最近 3 条消息的滑动窗口
recent = deque(maxlen=3)
for i in range(5):
recent.append(i)
# i=0: [0]
# i=1: [0, 1]
# i=2: [0, 1, 2]
# i=3: [1, 2, 3] ← 0 被挤出
# i=4: [2, 3, 4] ← 1 被挤出
print(recent) # deque([2, 3, 4], maxlen=3)

这是实现"最近 N 项"“滚动平均值”"日志尾部缓冲"的惯用法,比手动维护 list 简洁得多。注意 maxlen 一旦设定不可更改。

rotate:旋转

rotate(n) 把队列元素整体向右旋转 n 步(n 为负则向左),超出部分回绕到另一端,O(k):

1
2
3
4
from collections import deque
d = deque(range(5)) # deque([0, 1, 2, 3, 4])
d.rotate(2) # deque([3, 4, 0, 1, 2]) —— 右端 3,4 转到左端
d.rotate(-1) # deque([4, 0, 1, 2, 3]) —— 左端一个转到右端

一个经典应用是约瑟夫环(报数出列):

1
2
3
4
5
6
7
8
9
10
11
from collections import deque

def josephus(n, k):
q = deque(range(1, n + 1))
result = []
while q:
q.rotate(-(k - 1)) # 把第 k 个 rotate 到左端
result.append(q.popleft())
return result

josephus(7, 3) # [3, 6, 2, 7, 5, 1, 4]

内部实现

deque 底层是双向链表,按块(block)分配:每个块存 64 个 PyObject 指针,块之间用指针连接。这种分块设计避免了纯链表每个元素一个节点的内存爆炸,同时保留了双端 O(1) 增删的特性。随机访问 d[i] 是 O(n)(要遍历块),所以 deque 不适合按下标随机读写——那是 list 的主场。

线程安全

dequeappendappendleftpoppopleft 是原子操作,可在多线程下安全用作生产者-消费者队列的基础:

1
2
3
4
5
6
7
8
9
10
11
12
from collections import deque
import threading

queue = deque()
def producer():
for i in range(100):
queue.append(i) # 线程安全
def consumer():
while True:
if queue:
item = queue.popleft() # 线程安全
# process item

不过生产环境通常直接用 queue.Queue(带阻塞和通知机制)或 asyncio.Queuedeque 适合无锁的轻量场景。

应用:BFS 广度优先搜索

deque 是图/树 BFS 的标准容器:

1
2
3
4
5
6
7
8
9
10
11
12
from collections import deque

def bfs(graph, start):
visited = {start}
queue = deque([start])
while queue:
node = queue.popleft() # O(1),用 list 会是 O(n) 导致整体 O(n²)
print(node)
for neighbor in graph[node]:
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)

listpop(0) 会让 BFS 从 O(V+E) 退化成 O(V²),这是面试和竞赛中常见的隐性性能坑。

Counter:计数专家

统计元素出现次数是高频需求。手写循环 + dict 没什么难度,但 Counter 把这件事以及围绕它的常见操作(取 top N、合并、做集合运算)封装得非常顺手。

基本用法

1
2
3
4
5
6
7
from collections import Counter

# 任何可迭代对象都可以喂给 Counter
c = Counter('abracadabra')
print(c) # Counter({'a': 5, 'b': 2, 'r': 2, 'c': 1, 'd': 1})
print(c['a']) # 5
print(c['z']) # 0 —— 不存在的键返回 0,而非 KeyError

“缺失键返回 0"是 Counter 与普通 dict 最直观的区别——这正是计数的语义:没出现就是 0 次。这也意味着你不能靠 if key in counter 之外的方式判断"真有零个"和"没出现过”,但实践中无所谓。

1
2
words = Counter('the quick brown fox jumps over the lazy dog the'.split())
print(words['the']) # 3

most_common:取前 N

1
2
3
4
c = Counter('abracadabra')
c.most_common(3) # [('a', 5), ('b', 2), ('r', 2)]
c.most_common() # 全部按次数降序返回
c.most_common()[-1] # ('d', 1) —— 最少出现的

底层用 heapq.nlargest 实现,取前 N 时是 O(n log k),比排序整个字典 O(n log n) 更优(当 N 远小于 n 时)。

elements:按计数展开

elements() 返回一个迭代器,把每个元素按其计数重复产出:

1
2
c = Counter(a=3, b=2, c=1)
list(c.elements()) # ['a', 'a', 'a', 'b', 'b', 'c']

注意顺序是按元素首次插入顺序,计数 ≤ 0 的元素会被忽略。

total:求和(Python 3.10+)

1
2
c = Counter(a=3, b=2)
c.total() # 5 —— 所有计数的总和

3.10 之前需要 sum(c.values())

算术与集合运算

Counter 重载了 +-&|,让"合并计数""取交集"变得优雅:

1
2
3
4
5
6
7
c1 = Counter(a=3, b=1)
c2 = Counter(a=1, b=2)

c1 + c2 # Counter({'a': 4, 'b': 3}) —— 加法:计数相加
c1 - c2 # Counter({'a': 2}) —— 减法:只保留正数结果,≤0 的丢弃
c1 & c2 # Counter({'a': 1, 'b': 1}) —— 交集:取最小计数
c1 | c2 # Counter({'a': 3, 'b': 2}) —— 并集:取最大计数

减法 c1 - c2 的语义值得注意:b 变成 1-2 = -1 ≤ 0,会被丢弃,所以结果里没有 b。如果你想保留零和负数(比如做"余额"追踪),需要用 + 配合负值或手动处理。

实战——统计两段文本合并后的词频:

1
2
3
4
5
from collections import Counter
text1 = 'apple apple banana'
text2 = 'apple cherry cherry cherry'
total = Counter(text1.split()) + Counter(text2.split())
print(total.most_common(2)) # [('apple', 3), ('cherry', 3)]

陷阱:Counter 不是普通 dict

1
2
3
c = Counter(a=2, b=-3)   # 允许负值/零值
list(c.elements()) # [] —— b 被跳过,因为 ≤ 0
c['b'] # -3 —— 但直接访问还在

Counter 允许零或负的计数(常用于临时扣减),但 elements()most_common() 等会忽略它们。把它当通用 dict 用时要注意 c['x'] 返回 0 会"凭空创建"键——其实不会,访问不存在的键返回 0 但不插入该键,这是 Counter 特意覆写的 __missing__

defaultdict:告别 KeyError

写分组逻辑时,下面这种模板代码无处不在:

1
2
3
4
5
6
7
# 传统写法:分组
groups = {}
for word in words:
key = len(word)
if key not in groups:
groups[key] = []
groups[key].append(word)

defaultdict 把"键不存在时自动初始化"这件事自动化了:

1
2
3
4
5
6
from collections import defaultdict

groups = defaultdict(list) # 工厂函数是 list,即空列表
for word in words:
groups[len(word)].append(word) # 键不存在时自动调用 list() 创建空列表
dict(groups) # 需要时转回普通 dict

工厂函数的选择

defaultdict 的参数是一个无参可调用对象(工厂函数),键缺失时调用它生成默认值:

1
2
3
4
5
defaultdict(list)        # 默认值 []
defaultdict(int) # 默认值 0 —— 配合计数
defaultdict(set) # 默认值 set()
defaultdict(dict) # 默认值 {}
defaultdict(lambda: 'N/A') # 任意默认值

defaultdict(int) 是经典的计数惯用法(虽然 Counter 更专业):

1
2
3
4
counts = defaultdict(int)
for ch in 'mississippi':
counts[ch] += 1 # int() 返回 0,可直接 += 1
dict(counts) # {'m': 1, 'i': 4, 's': 4, 'p': 2}

嵌套 defaultdict

需要多层嵌套字典时,defaultdict 可以递归构造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from collections import defaultdict
import json

# 二维计数表:行 → 列 → 次数
tree = lambda: defaultdict(tree)
data = tree()
data['2026']['Jan'] += 1
data['2026']['Feb'] += 3
data['2025']['Jan'] += 2

# defaultdict 不好直接 json 序列化,转成普通 dict
def to_dict(d):
if isinstance(d, dict):
return {k: to_dict(v) for k, v in d.items()}
return d
print(json.dumps(to_dict(data), ensure_ascii=False, indent=2))
# {"2026": {"Jan": 1, "Feb": 3}, "2025": {"Jan": 2}}

tree = lambda: defaultdict(tree) 这种自引用写法能构造任意深度的嵌套字典,访问 data['a']['b']['c']['d'] 不会报 KeyError,会逐层自动创建。注意序列化前要递归转成普通 dict

与 dict.setdefault 对比

1
2
3
4
# 用 setdefault 实现同样的分组
groups = {}
for word in words:
groups.setdefault(len(word), []).append(word)

setdefault 也能做到,但有微妙差异:defaultdict 只在 __getitem__d[k])触发缺失时调用工厂;setdefault 是显式调用。更重要的是性能——defaultdict(factory) 的工厂调用发生在 C 层,而 setdefault 每次都会构造默认值参数(即使键已存在,参数也会被求值):

1
2
3
4
5
# setdefault:每次循环都构造 [],即使键已存在(Python 会丢弃但已付出构造开销)
d.setdefault(key, []).append(x)

# defaultdict:仅在缺失时构造
d[key].append(x)

对热点循环,defaultdict 通常更快。

陷阱:意外的键创建

1
2
3
4
5
from collections import defaultdict
d = defaultdict(list)
_ = d['nonexistent'] # 只是想"看看有没有这个键"
print(d) # defaultdict(<class 'list'>, {'nonexistent': []})
# —— 键被自动创建了!

d[k] 访问会触发默认值创建,即使你只是查询。若想避免副作用,用 k in dd.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
2
3
4
5
6
7
from collections import OrderedDict

od = OrderedDict.fromkeys('abc')
od.move_to_end('b') # 把 'b' 移到末尾: OrderedDict([('a', None), ('c', None), ('b', None)])
od.move_to_end('b', last=False) # 移到开头
od.popitem(last=True) # 弹出末尾: ('b', None)
od.popitem(last=False) # 弹出开头

move_to_endpopitem(last=...)OrderedDict 独有的,普通 dict 做不到 O(1) 地把某个键挪到头尾。此外 OrderedDict 的相等比较考虑顺序

1
2
3
from collections import OrderedDict
OrderedDict([('a', 1), ('b', 2)]) == OrderedDict([('b', 2), ('a', 1)]) # False
{'a': 1, 'b': 2} == {'b': 2, 'a': 1} # True

需要"顺序敏感的相等"时,OrderedDict 仍是正确选择。

经典应用:LRU 缓存

OrderedDict 是手写 LRU(Least Recently Used)缓存的利器——move_to_end 标记"最近使用",popitem(last=False) 淘汰"最久未用":

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from collections import OrderedDict

class LRUCache:
def __init__(self, capacity: int):
self.capacity = capacity
self.cache = OrderedDict()

def get(self, key):
if key not in self.cache:
return -1
self.cache.move_to_end(key) # 命中即提到末尾 = 最近使用
return self.cache[key]

def put(self, key, value):
if key in self.cache:
self.cache.move_to_end(key)
self.cache[key] = value
if len(self.cache) > self.capacity:
self.cache.popitem(last=False) # 淘汰头部 = 最久未用

getput 都是 O(1)。标准库的 functools.lru_cache 装饰器底层正是用类似机制实现的(C 加速版)。

FIFO 队列式字典

需要"先进先出"地管理带值的键时,popitem(last=False) 直接给你 O(1) 出队:

1
2
3
4
5
from collections import OrderedDict
fifo = OrderedDict()
fifo['task1'] = 'done'
fifo['task2'] = 'pending'
fifo.popitem(last=False) # ('task1', 'done') —— 最早进入的先出

内部实现

OrderedDict 维护一个双向链表 + 哈希表:哈希表存键值,链表记录顺序。这使得 move_to_endpopitem 都是 O(1),但内存开销大于普通 dict。普通 dict 在 3.7+ 也有序,但它内部是"紧凑数组"结构,没有独立的链表节点,因此更省内存——这也是为什么除非需要那两个特殊方法,否则优先用普通 dict

ChainMap:层叠的字典视图

多个字典需要按优先级查找时,传统做法是 merged = {**defaults, **overrides}——但这是拷贝合并,会创建新字典,且无法反映后续的原字典修改。ChainMap 提供了一种零拷贝的视图:多个字典串成一条链,查找时从前往后找,第一个命中即返回。

基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
from collections import ChainMap

defaults = {'theme': 'light', 'font': 'sans', 'timeout': 30}
user_config = {'theme': 'dark', 'font': 'serif'}
cli_args = {'timeout': 60}

# 优先级:cli_args > user_config > defaults
config = ChainMap(cli_args, user_config, defaults)

config['theme'] # 'dark' —— 来自 user_config
config['timeout'] # 60 —— 来自 cli_args
config['font'] # 'serif' —— 来自 user_config
config['language'] # KeyError —— 三层都没有

ChainMap 不复制数据,它持有底层字典的引用,因此底层字典改动会实时反映:

1
2
defaults['language'] = 'en'
config['language'] # 'en' —— 立即可见

查找顺序与所有键

1
2
3
list(config.keys())     # 去重后的所有键,按首次出现顺序
list(config.values()) # 按优先级取出的值
config.maps # [cli_args, user_config, defaults] —— 暴露底层字典列表

maps 是可变的,你可以直接操作它来增删层:

1
2
config.maps.append({'debug': True})   # 追加最低优先级层
config.maps[0]['new'] = 1 # 写入会落到第一个 map(cli_args)

写操作只影响第一层

这是 ChainMap 最需要理解的语义:写操作(赋值、删除)永远作用在第一个 map 上

1
2
3
4
5
6
7
8
config['theme'] = 'solarized'
# 只修改了 cli_args['theme'],user_config 和 defaults 不变
print(cli_args) # {'timeout': 60, 'theme': 'solarized'}

del config['timeout']
# 删的是第一层的 cli_args['timeout']
# 但 config['timeout'] 现在会回落到……没有,因为只有 cli_args 有 timeout
# 实际上会 KeyError,除非 user_config/defaults 也有

del 一个"只存在于更深层"的键会抛 KeyError——因为删除也只看第一层。这种"读穿多层、写只入首层"的行为非常适合"配置覆盖"场景:读取时按优先级,写入时只改最高优先级(如命令行参数),不动默认值。

new_child 与 parents

1
2
3
4
5
base = ChainMap(defaults)
layered = base.new_child(user_config) # 在最前面加一层
# 等价于 ChainMap(user_config, defaults)

layered.parents # ChainMap(defaults) —— 去掉第一层后的视图

new_child 返回一个新的 ChainMap(不修改原链),在最前插入一层;parents 返回去掉第一层后的视图。这让"作用域栈"的模拟很自然——每进入一层作用域 new_child,退出时用 parents

应用:层叠配置 / 作用域

1
2
3
4
5
6
7
8
9
from collections import ChainMap
import os

# 配置优先级:环境变量 > 用户配置 > 系统默认
config = ChainMap(
{k: v for k, v in os.environ.items() if k.startswith('APP_')},
load_user_config(),
system_defaults,
)

{**a, **b, **c} 合并的区别:合并是快照(一次性拷贝,之后各字典互不影响);ChainMap实时视图(底层变化即反映),且省内存。需要不可变快照时用合并,需要动态层叠时用 ChainMap

UserDict / UserList / UserString:继承友好的包装

为什么不直接继承 dict

很多教程教人"自定义字典"时直接 class MyDict(dict),这其实是个坑:

1
2
3
4
5
6
7
8
class MyDict(dict):
def __setitem__(self, key, value):
print(f'setting {key}={value}')
super().__setitem__(key, value)

d = MyDict()
d['a'] = 1 # 触发 __setitem__,打印 setting a=1
d.update({'b': 2}) # 不打印!update 在 C 层直接操作,绕过了你覆写的 __setitem__

dict 的方法是用 C 实现的,update__init__ 等并不会调用你覆写的 __setitem__/__getitem__,导致行为不一致。要让所有路径都走你的逻辑,需要覆写大量方法,极易遗漏。

UserDict 解决了这个问题:它把数据存在一个内部 dict 属性 data 里,所有方法都通过 data 间接操作,因此你覆写 __setitem__ 等基础方法后,update 等高级方法也会正确调用它们:

1
2
3
4
5
6
7
8
9
10
from collections import UserDict

class CountingDict(UserDict):
def __setitem__(self, key, value):
print(f'setting {key}={value}')
super().__setitem__(key, value) # 实际写入 self.data[key] = value

d = CountingDict()
d['a'] = 1 # 打印 setting a=1
d.update({'b': 2}) # 也打印 setting b=2 —— 行为一致

UserList 与 UserString

同理,UserList 把数据存在 self.data(一个 list),UserString 存在 self.data(一个 str)。它们存在的核心动机一致:当你需要继承内置类型并覆写其行为时,避免 C 实现绕过覆写方法的陷阱

1
2
3
4
5
6
7
8
9
10
11
from collections import UserList

class SortedList(UserList):
def __init__(self, initlist=None):
super().__init__(sorted(initlist) if initlist else [])
def append(self, item):
import bisect
bisect.insort(self.data, item) # 保持有序插入

sl = SortedList([3, 1, 2]) # [1, 2, 3]
sl.append(2.5) # [1, 2, 2.5, 3]

何时用

现代 Python 中,如果你只是想"包装一个容器加点行为",UserDict/UserList 仍是推荐做法。但要注意:UserDict 不是 dict 的子类(它是 MutableMapping 的子类,内含一个 dict),因此 isinstance(UserDict 实例, dict)False——这在需要"看起来像 dict 但行为受控"时反而是优点(避免被误当 dict 直接传给 C 扩展)。UserList MutableSequenceisinstance(x, list) 同样为 False

如果只是给数据加方法而不改容器语义,优先考虑 dataclass;只有在确实需要自定义容器的存取行为时,才用 UserDict 系列。

collections.abc:容器协议与抽象基类

collections.abc(注意是子模块,必须 import collections.abcfrom 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
2
3
4
5
6
from collections.abc import Mapping, Sequence, Iterable

isinstance({'a': 1}, Mapping) # True —— dict 是映射
isinstance([1, 2, 3], Sequence) # True
isinstance('abc', Sequence) # True —— str 也是序列
isinstance((x for x in []), Iterable) # True

isinstance(x, Mapping) 而非 isinstance(x, dict) 更宽松——它接受任何实现了映射协议的对象(UserDictChainMap、各种 ORM 对象等),符合鸭子类型精神。同理 isinstance(x, Sequence)isinstance(x, (list, tuple)) 更通用。

一个实用场景:判断参数是"字典类"还是"列表类":

1
2
3
4
5
6
7
8
9
10
from collections.abc import Mapping, Sequence

def deep_flatten(obj):
if isinstance(obj, Mapping):
# 字典:递归展平值
return {k: deep_flatten(v) for k, v in obj.items()}
elif isinstance(obj, Sequence) and not isinstance(obj, (str, bytes)):
# 序列(排除 str/bytes,它们虽是序列但通常当整体处理)
return [deep_flatten(item) for item in obj]
return obj

注意 strbytes 也是 Sequence,处理嵌套结构时要单独排除,否则会把字符串拆成字符。

继承 ABC 获得混入方法

实现自定义容器时,继承对应 ABC 并实现少数必需方法,就能免费获得大量派生方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from collections.abc import Sequence

class Range2D(Sequence):
"""一个只存储 (x1, y1, x2, y2) 的矩形,按需生成点序列。"""
def __init__(self, x1, y1, x2, y2):
self.x1, self.y1, self.x2, self.y2 = x1, y1, x2, y2
def __len__(self):
return (self.x2 - self.x1 + 1) * (self.y2 - self.y1 + 1)
def __getitem__(self, index):
if index < 0 or index >= len(self):
raise IndexError
w = self.x2 - self.x1 + 1
x = self.x1 + (index % w)
y = self.y1 + (index // w)
return (x, y)

r = Range2D(0, 0, 2, 2)
len(r) # 9 —— 来自 __len__
list(r) # [(0,0),(1,0),(2,0),(0,1),...] —— 来自 __getitem__ + __len__
(1, 1) in r # True —— __contains__ 是混入的,自动用 __getitem__ 实现
r.index((2, 2)) # 8 —— index 也是混入的
r.count((0, 0)) # 1

只需实现 __len____getitem__inindexcount__iter____reversed__ 全部自动可用。这正是 ABC 的价值——用最少的方法实现换来完整的协议支持。

注册虚拟子类

ABC 支持"虚拟子类"注册:让一个没有继承关系的类被 isinstance 认为是某 ABC 的实例:

1
2
3
4
5
6
7
8
9
from collections.abc import MutableSequence

class MyList: # 注意:没有继承任何 ABC
def __len__(self): ...
def __getitem__(self, i): ...
# ...

MutableSequence.register(MyList)
isinstance(MyList(), MutableSequence) # True

这用于"声明式"地标注某个类符合某协议,而不强制继承。标准库中 builtins 的许多类型就是被注册为各种 ABC 的虚拟子类(如 dict 注册为 MutableMapping 的虚拟子类)。

历史坑:collections 中的旧别名

Python 3.3 起,collections 中的抽象基类被移到了 collections.abc,但保留了向后兼容的别名(collections.Mapping 等)。3.10 起这些别名被移除,必须从 collections.abc 导入:

1
2
3
4
5
# Python 3.10+ 报错
from collections import Mapping # ImportError

# 正确写法
from collections.abc import Mapping

如果你的代码还在用 from collections import Mapping,升级到 3.10+ 会直接报错,改为 collections.abc 即可。

性能与选型总结

理解每个工具的复杂度,才能在正确场景选出正确工具。

操作listdequeset/dictCounter
头部增删O(n)O(1)
尾部增删O(1)O(1)
随机访问 x[i]O(1)O(n)
成员测试 inO(n)O(n)O(1)O(1)
计数统计需手写原生支持

选型决策树:

  • 要计数、取 top N、做频率运算Counter
  • 要分组、自动初始化嵌套结构defaultdict
  • 要双端高效增删 / 滑动窗口 / BFSdeque
  • 要不可变、轻量、可哈希的记录namedtuple(或 dataclass(frozen=True)
  • 要 LRU / 顺序敏感的相等 / move_to_endOrderedDict
  • 要层叠的只读视图、零拷贝配置查找ChainMap
  • 要自定义容器存取行为UserDict / UserList
  • 要检查/实现容器协议collections.abc
  • 以上都不需要 → 普通 dict / list / set 就够了

一个常被忽视的原则:优先用内置类型,只在它们力不从心时才上 collectionscollections 的工具都是为特定场景优化的,在通用场景下并不一定比 dict/list 快(如 OrderedDict 内存大于 dictdeque 随机访问慢于 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。这些微小的选择,累积起来就是代码质量的差距。