写得久了你会发现,Python 内置的list、dict、tuple、set其实是"通用容器",它们解决的是"存不存得下、取不取得出"的问题;而collections解决的是"存得好不好、读起来像不像人话、运行起来快不快"的问题。我第一次系统性读完整collections源码和文档后,最大的感受是:原来标准库里早就替我把那些高频场景的轮子造好了,我之前却一直在手写 if 判断和循环去填内置容器的坑。这篇博文就把我实际项目中用collections的经验、踩过的坑、选型思路一次性整理出来。
1. collections 到底补了什么:内置容器到专用容器之间有一条很宽的缝
1.1 一个简单的词频统计暴露出的样板代码
先看一个再普通不过的需求:统计一串单词里每个词出现的次数。很多新手会这么写:
words = ["apple", "banana", "apple", "orange", "banana", "apple"] count = {} for w in words: if w not in count: count[w] = 0 count[w] += 1这代码没什么错,但它把"计数"这个本来很直观的语义,淹没在了if w not in count这种防御性样板里。如果团队里每个人写这种统计时都随手来一套自己的判断逻辑,代码风格会迅速失控。
用collections里的Counter,事情变成这样:
from collections import Counter words = ["apple", "banana", "apple", "orange", "banana", "apple"] count = Counter(words)两行,语义直接。Counter本身就是dict的子类,所以你能像操作字典一样取值、遍历、判断 key 是否存在。特别贴心的是它的__missing__逻辑:访问一个不存在的 key 时返回 0,而不是抛KeyError。
我从这个例子得到的启发是:collections表面上只是多给几个类,本质上它是在告诉开发者——先想清楚你的数据结构是什么,再开始写循环。内置类型是万能的,正因为万能,它不针对任何高频场景做优化。而collections里每个类型都对应一种非常具体、反复出现的数据组织模式。
1.2 一张表看懂 collections 的每个成员补的是哪个内置类型的位
collections模块的类不算多,但它们跟内置容器的对应关系很清晰:
| collections 类型 | 补充/替代的内置类型 | 解决的痛点 |
|---|---|---|
namedtuple | tuple | 元组只能用索引取值,data[0]没有可读性;命名后可以用data.name |
deque | list | 列表头部插入/删除是 O(n);双端队列两端操作都是 O(1) |
defaultdict | dict | 每次都要判断 key 是否存在,否则给个默认值 |
Counter | dict | 专门做元素频次统计,还带四则运算和 top-n |
OrderedDict | dict | 保持插入顺序,且能调整元素顺序 |
ChainMap | dict | 把多个字典逻辑上合并成一个,查询时逐层找 |
UserDict/UserList/UserString | dict/list/str | 方便子类化,绕开 C 实现的底层细节 |
注意我的用词是"补充"而不是"替代"。collections不是要你把所有dict都换成defaultdict,它是在特定场景下让代码更精确。判断标准很简单:如果某种内置类型用起来让你反复写配套的样板代码,那就是它在提醒你——也许有更专门的数据结构。
有个容易被忽略的细节:set在collections里没有直接对应物,但Counter在语义上可以做"多重集合"(multiset),因为一个元素可以出现多次。我后面会详细讲Counter的运算,它就是冲着这个方向去的。
1.3 为什么标准库愿意维护这些"专用类型"
有人会问:既然我可以自己写个函数包装dict来实现默认值功能,为什么标准库要单独维护一个defaultdict?答案很实在:性能和一致性。
defaultdict、deque、Counter这些核心类型的底层大多是用 C 实现的,它们跟内置类型一样快,甚至更快。你自己用 Python 代码包一层,且不说逻辑上容易出 bug,光是每次查 key 都要经过一层 Python 函数调用,就已经慢了一个量级。标准库把它实现成原生类型,等于把高频模式的高性能版本直接送到你手上。
此外,这些类型在 CPython 里经过了二十来年的打磨,各种边界行为都被定义得很严谨。比如deque的maxlen限制、Counter的most_common排序行为,都是社区反复讨论过的结果。自己造轮子不是不行,但你得为所有边界条件负责,而标准库已经帮你写好了这些答案。
2. namedtuple:给 tuple 字段命名之后,代码的可读性完全不同
2.1 裸元组为什么容易变成"魔法数字索引"
我在代码评审里最怕看到两种写法:一种是 dict 里塞一堆键,另一种是函数返回一个裸元组,然后调用方照着result[0]、result[1]、result[2]去取数据。比如:
def get_point(): return 1, 2 # x = 1, y = 2 point = get_point() print(point[0]) # 你知道 0 是 x 吗?如果这个函数在项目里流传了半年,后来的人接手时根本不知道point[0]到底是什么。更麻烦的是,哪天返回结构里多加一个字段,所有用索引访问的代码全都得跟着改,稍不留神就会错位。
解决方案就是namedtuple:
from collections import namedtuple Point = namedtuple("Point", ["x", "y"]) point = Point(1, 2) print(point.x, point.y) # 1 2它本质上仍是元组,占用一块连续内存,支持拆包、支持索引访问、支持比较,只不过额外给了每个位置一个名字。从元组继承这一点很重要,意味着所有原本用元组的地方都可以平滑替换成它,比如把多个值塞进集合、作为字典的 key。
2.2 namedtuple 的创建与常用方法细节
namedtuple是个工厂函数,调用后会生成一个新的元组子类。构造时第一个参数是类名,第二个参数是字段名,字段名可以是字符串列表,也可以是一个带空格分隔的字符串:
Point = namedtuple("Point", "x y") # 还可以给字段设置默认值,注意默认值从右往左对齐 Person = namedtuple("Person", ["name", "age", "country"], defaults=["中国", None]) p = Person("张三", 18) print(p.country) # 中国子类生成后自带几个非常实用的方法:
_make(iterable):从一个可迭代对象批量创建实例,等价于Point(*some_tuple)_asdict():转成OrderedDict,做 JSON 序列化时特别好用_replace(**kwargs):基于现有实例创建一个新实例,只替换指定字段,适合"修改一个字段但保持不可变"的需求_fields:拿到字段名的元组,可以用它做反射或动态处理
举个例子,坐标移动的场景:
p = Point(2, 3) p2 = p._replace(x=5) print(p2) # Point(x=5, y=3) print(p) # Point(x=2, y=3),原实例没变这种不可变特性让namedtuple在并发环境下很省心,因为实例一旦创建就不会被外部不小心改掉。不过要注意,它并非绝对不可变——如果字段本身是可变对象(比如 list),那通过字段修改 list 的内容依然做得到。这是所有不可变容器的通病,不是它独有的。
还有两个稍冷门的参数值得知道。rename=True会在字段名非法或者跟 Python 关键字冲突时,自动把违规名字改成_0、_1这种带下划线的名字,避免整个定义过程直接抛错。module参数可以指定生成的类所属模块,这在需要 pickle 序列化的场合很重要,否则你可能遇到"无法定位 namedtuple 类定义"的报错。
2.3 namedtuple 与 dataclass 的选型边界
看到这里你可能会问:Python 3.7 之后不是有dataclass吗?它也能定义命名字段,还支持类型注解和默认值,是不是可以直接取代namedtuple?
我的观点是:二者定位不同,不应该互相取代。
namedtuple的优点是轻量、内存占用小、性能更接近原生元组、天然支持拆包和字典 key。如果你的数据只是一个"带名字的只读记录",没有方法逻辑,比如坐标、RGB 颜色、API 返回的临时数据,用它最合适。
dataclass的优点是灵活,可以定义可变实例、写方法、类型注解、继承关系。当你需要的是一个有行为的对象,或者字段会在生命周期里被频繁修改,再或者你需要 IDE 补全时,选它。
举例来说,一个"用户信息"对象如果只是从配置里读出来、传给其他函数展示,我倾向namedtuple;如果这个用户对象有自己的权限校验方法、状态会从 active 变成 disabled,那我用dataclass。记住一个很实用的判断标准:你需要不可变和元组语义时选namedtuple,你需要可变状态和方法时选dataclass。
还有一类特殊场景:当你在写性能敏感的内层循环,大量创建小记录对象时,namedtuple的内存和 GC 压力明显小于dataclass。我在数据管道里曾用namedtuple替代过一版dataclass,处理千万级记录时总耗时能降不少。
3. deque:双端队列如何在高频增删场景里跑赢 list
3.1 list 头部操作是 O(n),这个代价在真实项目里是慢慢暴露的
Python 的list底层是一段连续内存的数组,所以append和pop在末尾操作时是 O(1)。但如果你用list.insert(0, x)或者list.pop(0),底层会触发所有元素的整体搬移,左边空出一个位置或消除一个空位,时间复杂度是 O(n)。
一开始数据量小的时候感觉不到,几百个元素随便折腾。但如果这个操作出现在一个持续运行的循环里,比如 BFS 搜索、消息队列、滑动窗口,随着数据量增长,O(n) 的开销会迅速放大。我见过一个线上消息批处理脚本,每天跑一次要处理几十万条消息,只是因为在 FIFO 队列实现上用了list.pop(0),导致任务跑到后半段越来越慢,直到干脆超时。
这种"能用但性能诡异"的代码是最坑的,因为它在小数据量测试时一切正常,一到生产就暴露。
3.2 deque 的核心方法与 maxlen 滑动窗口
deque(double-ended queue,双端队列)从头到尾是一块双向链表加一块块分页缓冲区实现的结构,所以它在两端插入和删除都是 O(1)。它暴露的方法完全围绕"两端操作"设计:
from collections import deque d = deque() d.append(1) # 右端入 d.appendleft(2) # 左端入 print(d.pop()) # 右端出,得到 1 print(d.popleft()) # 左端出,得到 2 d.extend([3, 4]) # 右侧批量入 d.extendleft([5, 6]) # 注意:左侧逐个入,结果是 6,5,3,4 d.rotate(1) # 整体向右循环移动一位最常用也最容易被低估的参数是maxlen。它限定了队列的最大长度,超过这个长度时,新元素从一端进来,另一端的旧元素会被自动丢弃。这正是滑动窗口的天然实现:
from collections import deque window = deque(maxlen=5) for i in range(10): window.append(i) print(window) # 始终只有 5 个元素输出效果是这样的:
deque([0], maxlen=5) deque([0, 1], maxlen=5) deque([0, 1, 2], maxlen=5) deque([0, 1, 2, 3], maxlen=5) deque([0, 1, 2, 3, 4], maxlen=5) deque([1, 2, 3, 4, 5], maxlen=5) ...注意我实际运行时打印出来的内容比较多,这里就不全列了。核心点是:你不再需要手动判断"长度到了就删掉最旧的一条",数据结构直接替你做了。这种代码不仅少写几行,更重要的是不容易漏掉边界条件。
3.3 两个适合 deque 的真实场景:BFS 队列和日志滚动
场景一:BFS(广度优先搜索)。用list做队列时,最标准的错误就是queue.pop(0)。正确做法是用deque:
from collections import deque q = deque([start_node]) visited = {start_node} while q: node = q.popleft() for neighbor in graph[node]: if neighbor not in visited: visited.add(neighbor) q.append(neighbor)popleft()是 O(1),整个 BFS 的时间复杂度才能维持在 O(V + E)。用list.pop(0)的话,理论复杂度直接飙到 O(V²),图一旦变大就缓不过来。
场景二:日志滚动。假设你的程序要保留最近 100 条用户操作记录,同时需要随时取最后一条。deque(maxlen=100)完美匹配:每次append新记录,最老的一条自动被顶掉。如果需要把日志整体读出来,直接对这个 deque 做迭代,顺序和插入顺序一致。
这里要特别提醒:deque对中间位置的随机访问是 O(n),也就是说d[50]会从头或尾部开始往后走,并不是数组那种内存寻址。它没有切片能力(Python 3.13 之后有了有限切片/索引增强?实际上官方文档在 3.13 的deque.__getitem__仍然是 O(n) 的,而且不支持切片)。所以如果你需要频繁按下标随机读元素,还是老老实实用list,别拿 deque 硬撑。
3.4 提醒:deque 不是线程安全的生产消费队列
很多新手看到 deque 有append和popleft,就以为它是"线程安全队列",想在多线程程序里直接当任务队列用。这是个大坑。
deque的单个方法操作是线程安全的,但"先判断空再取元素"这种复合操作不是原子的。两个线程同时检查到队列非空,然后一个popleft,另一个可能就抛IndexError。更细的问题在于阻塞等待:当队列为空时,deque 会立刻抛异常,做不到"等生产者产出数据"。
这种场景应该用queue.Queue。它内部就是用deque实现的,但在外面包了线程锁和条件变量,提供了get(timeout=...)、put(timeout=...)这种带阻塞的 API。记住一句话:deque 解决的是"双端 O(1) 增删"的数据结构问题,queue 解决的是"多线程间安全传递任务"的同步问题,两者完全不是一回事。
4. defaultdict 与 Counter:分组、计数、聚合的样板代码该删掉了
4.1 defaultdict 的默认值工厂是如何消灭 if key not in 的
defaultdict是dict的子类,核心机制是:当访问的 key 不存在时,会自动调用你传入的工厂函数生成默认值,并且写入字典。最常见的三种工厂是list、set、int,分别对应分组、去重、计数。
分组是最典型的场景。以"按性别分组成员的用户名列表"为例,纯 dict 的写法要处理"这个 key 是不是第一次出现":
data = [("male", "张三"), ("female", "李四"), ("male", "王五")] groups = {} for gender, name in data: if gender not in groups: groups[gender] = [] groups[gender].append(name)用defaultdict:
from collections import defaultdict groups = defaultdict(list) for gender, name in data: groups[gender].append(name)少了两行判断,但更重要的是少了一种错误可能:万一哪次忘了if分支,直接groups[key].append()就会因为 KeyError 崩溃。defaultdict把这种防御变成了结构的一部分。
同理,defaultdict(set)面向去重场景,defaultdict(int)面向计数场景。它们都不难,难的是你形成"看到分组就想到 defaultdict"的条件反射。
嵌套场景稍微复杂一点。比如你想维护一个两层的结构:"班级 -> 姓名 -> 分数列表":
nested = defaultdict(lambda: defaultdict(list)) nested["一班"]["张三"].append(95)lambda: defaultdict(list)的意思是:外层第一次访问"一班"时,自动创建一个新的defaultdict(list)作为值;内层的"张三"再不存在时,自动创建空列表。这种写法在做数据透视、多级聚合时特别顺手。
4.2 Counter 不只是计数器:四则运算与 most_common
Counter是collections里我使用频率最高的类。它继承自dict,专门做计数,但它的能力远超简单的c[k] += 1。
最基础的是most_common,一键拿到出现次数最多的前 N 个元素:
from collections import Counter c = Counter("hello world") print(c.most_common(3)) # [('l', 3), ('o', 2), ('h', 1)]然后是它的四则运算,这个特性用好了可以写出非常简洁的聚合逻辑。两个 Counter 相加时,相同 key 的计数累加;相减时,计数相减并丢弃非正数的项;&是交集,取每个 key 的最小计数;|是并集,取最大计数。
c1 = Counter(a=3, b=1) c2 = Counter(a=1, b=2, c=3) print(c1 + c2) # Counter({'a': 4, 'b': 3, 'c': 3}) print(c1 - c2) # Counter({'a': 2}),b: 1-2=-1 被丢弃 print(c1 & c2) # Counter({'a': 1, 'b': 1}) print(c1 | c2) # Counter({'a': 3, 'b': 2, 'c': 3})我在做推荐系统候选分析时,经常用&来求两个用户都反复交互过的标签集合,用+来合并多日的行为计数,一行代码抵掉原来的好几轮循环。
Counter的update方法也很有个性。传入可迭代对象时,它会自动统计可迭代对象里每个元素的出现次数并累加;传入字典或 Counter 时,则按 key 累加。这种"两种更新模式"恰好对应了两种需求:从原始数据增量统计,以及合并另一个计数结果。
还有一个贴心设计是elements(),它会按计数展开每个元素:
c = Counter(a=2, b=1) print(list(c.elements())) # ['a', 'a', 'b']这在需要"把计数结果还原成元素列表"的场景下很有用,比如按权重抽样前先展开成列表。
4.3 容易翻车的几个细节:默认值工厂、负数计数与 Counter 的加减
第一个坑是给defaultdict传错默认值工厂。有人图省事写成defaultdict({}),这是错的。defaultdict的第一个参数必须是可调用对象,{}是一个实例而不是可调用对象,访问不存在的 key 时会直接抛TypeError。正确写法是defaultdict(dict)或者defaultdict(lambda: {})。
还有一个隐蔽的坑:把同一个可变对象当默认值。比如:
shared = [] d = defaultdict(lambda: shared)这会导致所有新 key 都共享同一个列表,往d["a"]里 append 的元素也会出现在d["b"]里。如果你用的是defaultdict(list),每次会调用list()创建新列表,不会有这个问题;一旦你改成 lambda 或其他自定义工厂,就必须确认它返回的是新对象。
第二个坑是Counter可能出现负数计数。subtract方法允许计数相减后保留负值,跟-运算符丢弃负数的行为不一样:
c1 = Counter(a=3) c2 = Counter(a=5) c1.subtract(c2) print(c1) # Counter({'a': -2})负数计数本身不是 bug,但如果你后续用most_common或者把 Counter 正常输出,负数项会影响结果呈现。在写统计逻辑时,要想清楚"负值"在你的业务里是否有意义。
第三个细节是:Counter访问不存在的 key 时返回 0,但它不会像defaultdict那样把 key 自动写进去。也就是说c["x"]的读取不会导致"x"出现在 Counter 里,只有赋值或update才会真正写入。如果你期望的是"读一次就自动带上默认值",那要用defaultdict(int);如果你想要的是"统计次数并支持缺失即 0",用Counter。二者定位有区别。
5. ChainMap 与 OrderedDict:dict 补充里容易被低估的两个成员
5.1 ChainMap 做多层配置合并,比 update 更符合直觉
ChainMap做的事情是把多个字典"逻辑上"拼接成一个,查询时从前到后逐个找,返回第一个命中的 key。它不是真的把字典合并,而是保存了各层字典的引用。
最典型的使用场景是分层配置:默认配置、环境变量、用户自定义配置,按优先级覆盖。用ChainMap写:
from collections import ChainMap defaults = {"theme": "light", "lang": "zh", "cache_size": 128} env_config = {"lang": "en"} user_config = {"theme": "dark"} merged = ChainMap(user_config, env_config, defaults) print(merged["theme"]) # dark print(merged["lang"]) # en print(merged["cache_size"]) # 128如果只用普通 dict,你通常要用update一层层覆盖,这会生成一个全新的字典,并且丢掉"层级关系"的语义。ChainMap的好处是:
- 不复制底层数据,内存开销小,构建瞬间完成
- 修改
merged["key"] = value时,只会写入最前面那一层字典,不会污染默认配置 - 各层字典后续如果发生变化,ChainMap 查询结果会同步变化,因为它是引用不是拷贝
它还提供了new_child()方法,在现有链前面加一层新字典;以及parents属性,去掉最前面一层后得到剩下的链。这在处理嵌套作用域、模板渲染上下文、命令行参数覆盖等场景下非常有用。
我特别推荐在解析配置文件的代码里使用它。比如程序启动时加载一份默认配置,然后读用户目录下的配置文件,最后再覆盖命令行的指定项,一个ChainMap就把三级优先级表达得清清楚楚,不需要写多个临时变量挨个 update。
5.2 OrderedDict 在 Python 3.7+ 还有什么不可替代的价值
Python 3.7 起官方保证普通 dict 的插入顺序,很多人因此觉得OrderedDict没必要存在了。这个判断在大部分场景是对的,但OrderedDict有几个普通 dict 没有的能力,在小众场景里不可替代。
一是move_to_end(key, last=True),可以把一个已有 key 移动到末端。普通 dict 想实现同样的效果,只能先 pop 再重新插入。二是popitem(last=False),默认是从末端弹出,也可以传last=False从头部弹出。这两个方法在实现 LRU 缓存时价值很大——我后面会提到。
另外有个容易忽略的差异:两个 dict 比较是否相等时,不管插入顺序有多不同,只要键值对相同就算相等。但两个OrderedDict比较时,不仅要求键值对相同,还要求顺序相同。这在某些需要"严格相等"语义的逻辑中非常重要。
from collections import OrderedDict o1 = OrderedDict([("a", 1), ("b", 2)]) o2 = OrderedDict([("b", 2), ("a", 1)]) print(o1 == o2) # False print({"a": 1, "b": 2} == {"b": 2, "a": 1}) # True如果你的代码依赖"顺序也是一种状态",比如某些协议报文的字段顺序、渲染模板的字段顺序,用普通 dict 比较可能会得出错误结论。
5.3 UserDict 一类:当你确实想继承 dict 的时候
UserDict、UserList、UserString这三个类是内置容器的包装器,专门给"想继承容器定制行为"的场景准备的。直接继承内置类的坑在于,它们的方法底层大量走 C 实现的捷径,子类里重载的方法在某些路径下不会被调用。比如你继承dict并重写了__setitem__,在update方法里可能不会生效,因为底层直接操作了 C 结构。
UserDict绕过这个问题的方式是:内部存一个真实的 dict 作为self.data,所有公开方法都通过 Python 层调用,这样你重载的方法能稳定地生效。
举例,我想实现一个"所有 key 必须是字符串"的字典:
from collections import UserDict class StringKeyDict(UserDict): def __setitem__(self, key, value): if not isinstance(key, str): raise TypeError("key must be str") super().__setitem__(key, value) sd = StringKeyDict() sd["ok"] = 1 # 正常 sd[123] = 2 # 抛 TypeError用普通dict子类做这事,update等其他入口就未必能拦截。UserDict让自定义容器行为这件事变得可预测。如果你只是想要一个带默认值的 dict,直接defaultdict就好,没必要上UserDict;只有当你需要深度定制行为时才需要它。
6. 选型清单与实战踩坑记录
6.1 一张场景到类型的速查表
项目做多了之后,我基本形成了一套肌肉记忆。这里整理成一张速查表,适合贴在代码评审规范里:
| 需求场景 | 首选类型 | 为什么不选内置/其他 |
|---|---|---|
| 统计元素出现次数,取 top-n | Counter | dict手写计数太啰嗦,defaultdict(int)拿不到most_common运算 |
| 给元素分组,按 key 构建列表 | defaultdict(list) | 手写 if 判空容易漏分支,嵌套场景更乱 |
| 按优先级合并多层配置 | ChainMap | update破坏层级关系,且多了一次复制内存 |
| 固定长度滑动窗口/滚动日志 | deque(maxlen=N) | list需要手动弹出旧值,而且头部操作 O(n) |
| BFS/双端操作 | deque | list.pop(0)O(n),大图会拖垮性能 |
| 不可变记录对象 | namedtuple | 裸 tuple 没语义,dataclass太重且有可变性风险 |
| 需要保持顺序并频繁调整顺序 | OrderedDict | 普通 dict 3.7+ 虽保持顺序,但没有move_to_end |
| 自定义 dict 行为,比如类型校验 | UserDict | 直接继承 dict 时 C 底层会绕过部分重载方法 |
6.2 我实际踩过的几个坑
第一个坑是namedtuple的字段名里有空格或标点。定义时不会立刻报错,用到的时候才发现_fields解析不对。正确做法是字段名里只用字母、数字、下划线,且不能以数字开头。工具函数本身会校验,但建议命名时就想清楚。
第二个坑是把一个deque(maxlen=100)数据流里的对象直接传到别的线程处理。虽然单个 append/popleft 是原子操作,但"取出来 + 处理 + 入列"的组合过程依然需要同步锁。我早期拿 deque 当跨线程任务队列,结果出现数据丢失,排查了很久才意识到是并发问题而不是代码逻辑问题。从那时起我就记住了:多线程之间的传递用queue.Queue,deque 只负责数据结构层面的双端操作。
第三个坑是Counter和defaultdict混用时产生的心智负担。比如我用Counter加载已有数据,然后又用if key not in c: c[key] = 0这种 defaultdict 风格的判断,完全没必要。Counter 可以直接拿c[key],因为它对缺失 key 返回 0。这不算 bug,但两种"缺失即默认"的语义同时出现在一段代码里,容易让读者困惑。
第四个坑是性能测量的误判。我曾经在一个页面请求里用deque存中间结果,然后频繁按下标访问d[100]这种位置,结果性能反而比 list 差。后来用timeit测了一下才知道,deque 的随机访问会从头或尾遍历到目标位置,而 list 是 O(1) 的内存寻址。两端高频增删用 deque,随机访问多就用 list,这个边界一定要分清。
6.3 什么时候不要用 collections
collections很好用,但绝不是越多越好。我的建议是:
小型脚本、一次性处理中,如果内置 dict 和 list 已经写得很顺,没必要特意引入defaultdict或deque来显得"专业"。代码的可读性来源是结构清晰,而不是类型花哨。一个只在 10 行代码里用的普通 dict,改成defaultdict反而增加读者的概念负担。
性能优化时也别急着换容器。先用profile或timeit找到真正的热点,确认瓶颈确实出在"头部插入"或"判空循环"上,再考虑换类型。很多时候更值得优化的是循环算法本身,比如把重复的 O(n) 查找改成 O(1) 的字典查询。容器类型的选型是锦上添花,不是雪中送炭。
还有一个容易忽略的点:namedtuple适合定义"静态结构"的记录,不适合承载带有复杂业务逻辑的对象。如果你发现给记录加的方法越来越多,甚至开始维护状态转换,那就该换成dataclass或普通类了。在错误的抽象上继续加功能,成本会越来越高。
6.4 每做一个真实项目,我都会重新审视一遍容器选择
最后分享一个我个人的工作习惯。每次接手一个 Python 项目,我会先全局搜索两种代码模式:一是手写的if key not in dict_逻辑,二是list.pop(0)。这两个模式出现的地方,大概率就是defaultdict和deque的潜在价值点,但不一定都要替换,需要结合数据量级和调用频率判断。
替换的时候,我会顺手写一个几行的微基准测试。比如要评估 list 和 deque 在某个场景下的差异,简单的timeit就够了:
import timeit from collections import deque list_time = timeit.timeit( "l.insert(0, 1)", setup="l = list(range(10000))", number=10000 ) deque_time = timeit.timeit( "d.appendleft(1)", setup="from collections import deque; d = deque(range(10000))", number=10000 ) print(f"list insert(0): {list_time:.4f}s") print(f"deque appendleft: {deque_time:.4f}s")这种测试的意义不在于得到一个绝对性能数值,而在于让自己对"O(n) 和 O(1) 的差距到底有多大"保持体感。实测下来,数据量上万之后,list.insert(0)和deque.appendleft的差距会拉到两个数量级以上,这是看文档很难建立的直觉。
数据结构的正确选型不会直接让你写出业务功能,但能让这些功能在数据量增长之后还能保持稳定。collections 的价值恰恰就在于此:它把这些工程上反复出现的优化点,沉淀成了标准库的一部分,让你不需要每次从零设计。