带团队这两年,我被问得最多的Python问题,不是难懂的元编程,也不是某个框架的高级API,而是看起来很基础却卡住很多人的场景:“我这段代码里一直在list里用in查元素,为什么几万条数据就跑不动了”“一个差不多的业务,别人用dict几秒出结果,我用list要几分钟”。这些问题追根溯源,都通向同一个概念——容器。先澄清一个容易混淆的点:本文说的容器不是Docker那种虚拟化容器,也不是Java里的Spring容器,而是Python内置的list、dict、set、tuple这一组数据结构。天天在用,但很多人只停留在“会用”阶段:append、get、add,会几个API就开写。真正想把容器用出差距,至少要了解内存模型、复杂度边界和业务选型方法。这篇文章就按这个路径来:先建立正确认知,再讲底层原理,接着给实战选型,最后落到高性能写法和自定义容器。适合已经能写Python、但感觉数据量一大代码就变慢的开发者。
1. 先搞清楚:Python容器到底指什么,不止list/dict这么简单
1.1 容器协议:比记住具体类型更重要
很多初学者把“容器”理解成“就是那几个内置类型”。这个认知不能说错,但太窄了。在Python里,一个对象能不能被称为容器,取决于它实现了哪些协议,而不是它的名字叫list还是dict。
collections.abc这个模块就是用来干这个的。它定义了一批抽象基类:Container表示对象支持in操作,需要实现__contains__;Iterable表示可以被for遍历,需要实现__iter__;Sized表示可以取长度,需要实现__len__。再往上还有Sequence、MutableSequence、Set、MutableSet、Mapping、MutableMapping这些更具体的分类。
举个例子,我完全可以写一个不属于任何内置类型的类,让它表现得很像容器:
from collections.abc import Container class MyBox: def __contains__(self, item): return item in self.data box = MyBox()只要实现了__contains__,item in box就能跑,isinstance(box, Container)也是True。这意味着什么?意味着在写业务代码时,函数参数的类型标注应该尽量用协议类型,而不是具体的list或dict。
我自己有一条代码规范:参数声明为Iterable或Mapping,内部按协议使用。调用方传list、tuple、set都行,传自定义的容器也行。一旦把参数钉死在list上,调用方为了配合你,常常要复制一堆数据改成list,性能和内存双双吃亏。
1.2 三张名片:有序性、可变性、唯一性
内置容器的差异,本质上可以用三个特征描述清楚。我把这六个常见类型整理成一张表:
| 容器类型 | 有序性 | 可变性 | 元素/键唯一性 | 典型用途 |
|---|---|---|---|---|
| list | 有序 | 可变 | 允许重复 | 按序存储、栈、随机访问 |
| tuple | 有序 | 不可变 | 允许重复 | 不可变记录、哈希键 |
| dict | 有序(3.7+) | 可变 | 键唯一 | 映射、索引、计数 |
| set | 无序 | 可变 | 唯一 | 去重、成员判断 |
| frozenset | 无序 | 不可变 | 唯一 | 可哈希的去重集合 |
| str | 有序 | 不可变 | 允许重复 | 文本 |
这里面有两个容易忽略的细节。第一,dict的有序性是3.7之后才成为语言规范保证的,3.6的CPython里它已经有序,但当时还只是实现细节。所以在老教程里看到的“dict无序”早已过时。第二,set的无序其实和哈希有关,元素的存储位置由哈希值决定,跨进程甚至可能因为PYTHONHASHSEED不同而不同,不能用“set的顺序”作为业务逻辑依据。
这三个特征直接决定选型。比如你有一个列表,里面可能有重复值,又要保持第一次出现的顺序,那么list(dict.fromkeys(items))就是一个常用技巧:dict的键保持插入顺序且唯一,转回list就得到了一个去重且保序的结果。而如果完全不关心顺序,直接set(items)更省事。
items = ["apple", "banana", "apple", "cherry", "banana"] deduped = list(dict.fromkeys(items)) # ['apple', 'banana', 'cherry']2. 多数人不知道的容器内存模型:省内存的正确姿势
2.1 list底层是指针数组:一个list并不“装”对象
要搞懂容器的高性能用法,绕不开内存模型。先看list。CPython里list的底层是一个PyListObject,它内部持有一个PyObject**数组。也就是说,list里存的不是对象本体,而是指向对象的指针,每个指针在64位系统上占8字节。
这个事实能解释很多现象。第一,list.append(x)均摊时间复杂度是O(1),因为它只是在指针数组的尾部追加一个指针,偶尔触发扩容。第二,list切片会创建一个新列表,但新列表里的元素指向的仍然是原来那些对象,这是浅拷贝。验证很简单:
a = [1, 2, 3] b = a[:] print(a[0] is b[0]) # True,引用同一个对象第三,如果你在list里放了很多独立创建的对象,list本身只承担引用开销,真正的内存大头在堆上。比如[[], [], []]创建了三个不同的空列表,每个空列表自身还要再占几十字节,整体开销远大于“三个指针”。
我见过一个真实案例:有人把200万条数据库记录映射成list[dict],每条记录里的字段名都是重复字符串。如果不小心让每条记录都持有了自己那份字符串对象,内存直接翻几倍。绕开这个问题的办法,在数据分析场景就是改用DataFrame统一管理列名,在普通业务场景则是尽量复用共享的枚举字符串对象。
2.2 小整数驻留与共享引用:“改一个、另一个跟着变”的根源
CPython对-5到256之间的整数做了全局缓存。也就是说,a = 100; b = 100,它们指向的是同一个整数对象,is比较返回True。但如果你用的是a = 1000; b = 1000,在大多数实现里它们不是同一个对象,is返回False。
对于不可变对象,这种共享引用没有副作用,反正改不了。但对可变对象,共享引用就是bug温床。最典型的就是嵌套list的乘法简写:
matrix = [[0] * 3] * 3 matrix[0][0] = 1 print(matrix) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]]原因很简单:[[0] * 3] * 3先把内层list创建好,然后用* 3复制的是引用,三个外层槽位指向同一个内层list。改matrix[0][0],等于改那个共享的内层list。
工程上有几点建议:数值比较永远用==而不是is;可变对象的“复制”要明确是浅拷贝还是深拷贝;初始化嵌套结构时,用推导式而不是乘法。
2.3 dict的散列存储:查找快,但有代价
dict查找为什么快?因为它底层是散列表,开放寻址法。查找key时,先计算hash(key),根据哈希值定位槽位,然后比较哈希值和key,平均O(1)。3.6之后的“compact dict”把索引数组和条目数组分离,内存比老版本紧凑不少,这也是为什么现在用dict存大量数据比以前更省内存的原因之一。
但“快”是有前提的。key对象必须可哈希,且哈希值在对象的生命周期内保持稳定。自定义类默认用id(self)做哈希,这没问题,但如果你重写了__eq__而没有同步重写__hash__,就会导致两个相等的对象哈希值不同,dict里出现“找不到键”的怪问题。
dict的另一个代价是内存。一个空list约56字节,一个空dict约64字节,看起来差别不大。但当元素数量上去后,dict为了维持低冲突率,装载因子通常保持在2/3以下,意味着有将近1/3的槽位是空的。加上键和值对象的开销,100万元素的dict轻轻松松占掉几百MB内存。数据规模小的时候,list的线性查找反而可能更快,因为散列计算和内存跳转的代价有时候比循环里比较几个字符串还要贵。我自己的经验:万级以内的数据量,list和dict的差距往往不明显;一旦上了百万级,dict的优势才真正拉开。
3. 实战选型:从业务场景反推该用哪种容器
3.1 去重和存在性检查:list、set、dict怎么选
如果你写的是if x in my_list,且my_list非常大,这一步就是O(n)。每次查询都遍历整个列表,循环套循环就变成O(n*m),数据一多就崩。改成my_set = set(my_list)之后,存在性检查变成O(1),整体是O(n+m)。
爬虫项目里的URL去重就是这个场景的经典代表。几百万条URL往list里塞,边塞边查,跑半小时都完不成;改用set之后速度快了几个数量级。代价是set内存比list大,因为除了指针还要维护哈希表。如果数据集大到set都放不下,就需要考虑分片加Bloom Filter做预过滤,把确定不存在的URL先在布隆过滤器里挡掉,再进set。这不是什么高深技术,但能把内存占用压到一个可接受的范围。
dict选型的区别在于它还能“带数据”。比如同一个URL还要保存抓取时间,那就是url_set的成员判断配合一个url_metadict存储元信息;如果这个URL本身需要映射到处理结果,直接用dict当索引,比set加list双结构更自然。
3.2 队列和双端操作:list.pop(0)为什么慢,deque怎么用
很多人写FIFO队列,第一反应是list.append入队、list.pop(0)出队。这在小数据量下没问题,但pop(0)是O(n)的操作——删除头部元素后,后面所有元素都要往前挪一位。数据量一大,队列操作就变成灾难。
collections.deque是双端队列,底层是分块链表结构,头尾两端的append和pop都是O(1)。BFS广度优先搜索的正确写法就是用它:
from collections import deque def bfs(graph, start): seen = {start} queue = deque([start]) while queue: node = queue.popleft() for neighbor in graph[node]: if neighbor not in seen: seen.add(neighbor) queue.append(neighbor)注意deque的随机访问按下标取值是O(n),如果既要高效地往两端加元素,又要频繁按下标读,这个需求本身就比较矛盾,通常需要重新考虑数据结构,而不是在list和deque之间折腾。
3.3 缺失键与统计场景:defaultdict与Counter的取舍
词频统计是最常见的容器操作之一。三种写法代表三种思路:
# 普通dict配合get hist = {} for word in words: hist[word] = hist.get(word, 0) + 1 # defaultdict from collections import defaultdict hist = defaultdict(int) for word in words: hist[word] += 1 # Counter from collections import Counter hist = Counter(words)defaultdict的便利之处是访问不存在的键会自动插入默认值。但这也是一个隐蔽的坑:如果某个键本不该被写入,你只是“读一下”,它也会被创建出来。比如d = defaultdict(int); x = d["no_such_key"],这行代码执行后,dict里真的多了一个键,值还是0。在只读逻辑里用defaultdict,很容易造成无意识的内存膨胀。
Counter是dict的子类,专门用于计数,most_common(n)拿Top K非常方便。选型建议:需要全量计数且对缺失键有处理需求,用defaultdict;只要统计频率并排序取前N,用Counter;不需要任何默认值语义,普通dict加get就够,代码反而更清晰。
3.4 不可变容器的价值:tuple、frozenset与默认参数
tuple和frozenset不可变,因此可以作为dict的键、set的元素。这在很多业务场景里特别有用。比如量化交易里的订单记录,一个订单包含(order_id, symbol, price),要对该集合去重,直接用set(orders),前提是每个订单存成tuple而不是list。list不可哈希,塞进set会直接报TypeError。
这里的“不可变”要理解到位:tuple保证的是它本身不能增删元素,但如果tuple内部包含list,这个list仍然可以被修改。所以需要“深度不可变”时,要么内部也全部用tuple和frozenset,要么干脆用封装好的数据类。
函数默认参数也值得说一句。习惯上大家默认用[]或{}作默认值,但可变默认参数在多次调用间会共享累积状态,这是经典bug。改成None然后函数内部初始化,或者直接用tuple这类不可变值,都可以规避风险。
def process_items(items=()): # 安全,tuple不可变 ...4. 高性能实战:让容器在真实项目中跑得更快
4.1 用推导式和生成器减少“往返次数”
同样的功能,列表推导式通常比for加append快20%到30%。原因不玄学:推导式的字节码里直接用了LIST_APPEND操作,而普通循环每轮都要执行append的属性查找和函数调用。
squares = [x * x for x in range(1_000_000)] # 对比 squares = [] for x in range(1_000_000): squares.append(x * x)dict推导式和set推导式同样受益。但当推导式的内部表达式非常复杂,为了可读性,我会拆成普通循环加辅助函数,让意图更清楚。高性能不只是“快”,也包括“别人能看懂”。
生成器是另一个维度:(x for x in big_list if condition)不会一次性占用内存,适合边遍历边处理的管道场景。但它只能迭代一次,不能往回看,有状态管理需求时不要用。
4.2 预分配数组:什么时候值得做
Python没有类似C++reserve的直接API,但可以通过[None] * n先建好固定长度的list,再按下标赋值,避免append过程中的多次扩容。
问题是,这个微优化未必划算。CPython的list扩容策略是均摊O(1),每次扩容约增加到原来的1.125倍,元素多时偶尔复制一次,均摊成本很低。只有当n提前知道、又特别大(百万级),且赋值逻辑简单时,预分配才值得写。
同样值得说的是array.array。如果容器里全是同一种数值类型,用array.array('d')比list省内存得多,因为底层是连续的C数组,而不是指针数组加一堆Python对象。但它的元素存取要经过Python层转换,读写速度不一定比list快。真正的海量数值计算还是得上NumPy,那是另一个话题了。
4.3 遍历时修改容器:三个高频坑的解法
遍历时修改容器,几乎是所有Python踩坑榜单的常客。
第一个坑是list删除元素跳过。以下代码会漏删:
lst = [1, 2, 2, 3, 2] for x in lst: if x == 2: lst.remove(x)每删一次,后续元素往前挪,for循环按原下标继续走,就会跳过紧挨着的那个重复值。更稳的写法是一次性过滤重建:lst[:] = [x for x in lst if x != 2]。
第二个坑是遍历dict时修改大小。for k in d: d.pop(k)会直接抛RuntimeError: dictionary changed size during iteration。解决办法是遍历之前先取快照:for k in list(d):,或者先收集要删的键,遍历结束后再批量删。
第三个坑在set里同理:迭代过程中不能增删元素。规则不复杂,但真到了项目debug阶段,看到“size changed during iteration”这种报错还是会让人愣一下,提前记住能省不少时间。
4.4 嵌套容器构建的正确姿势
前面提到的matrix = [[0] * 3] * 3是嵌套容器最经典的坑。正确写法是:
matrix = [[0] * 3 for _ in range(3)]另一个容易翻车的是dict.fromkeys配合可变默认值:
d = dict.fromkeys(keys, []) # 所有key共享同一个list d["a"].append(1) print(d["b"]) # [1],异常正确做法也是推导式:d = {key: [] for key in keys}。底层逻辑都一样:乘法或fromkeys复制的是引用,不是创建新对象。凡是要给每个键/每个位置一个独立可变对象的地方,都用推导式重新创建。
这种问题在单元测试里几乎测不出来,因为数据量小,共享引用造成的污染不容易被发现。等数据一上量,业务逻辑不断修改这些嵌套结构,脏数据就悄悄蔓延开来。
4.5 从O(n^2)到O(n+m):线性查找改哈希查找的真实案例
讲一个非常常见的性能优化:一批user_id需要从白名单里过滤。白名单列表有50万条,待过滤的用户ID有10万条。如果代码长这样:
allowed = [u for u in all_user_ids if u in white_list]看起来一行很优雅,实际复杂度是O(10万×50万),等于500亿次比较,跑完面条都凉了。改法很简单:
white_set = set(white_list) allowed = [u for u in all_user_ids if u in white_set]先花O(n)把list转成set,再用O(m)完成过滤,总复杂度O(n+m)。如果内存紧张,还可以分批转set、分批过滤。这个案例可能太简单,但认真想想,生产代码里很多慢查询都源于“该用哈希容器的地方用了线性容器”。
5. 自定义容器:当内置类型不够用的时候
5.1 实现协议,让你的对象成为一个“真容器”
内置类型之外,你完全可以写自己的容器。关键不在于继承list或dict,而在于实现协议。最基础的是这三个:__contains__支持in,__iter__支持for,__len__支持len()。更进一步的Sequence还需要__getitem__。
一旦你的类正确实现了这些方法,就可以注册到collections.abc,让isinstance检查也生效:
from collections.abc import Sequence class MySequence(Sequence): def __init__(self, items): self._items = list(items) def __getitem__(self, index): return self._items[index] def __len__(self): return len(self._items)体会一下,MySequence不需要自己实现index、count或__contains__,因为Sequence这个抽象基类会基于__getitem__和__len__派生出这些方法。这是Python容器体系里一个非常强大的特性,但很多人不知道。
工程选型上,我倾向于“组合优先于继承”:自定义容器内部持有原生list,然后重定向协议方法。继承list虽然方便,但继承链深了之后,某些C层优化可能绕过重写的方法,行为诡异。组合加协议实现,心智负担小得多,行为也可预期。
5.2 复制一个容器:浅拷贝、深拷贝、以及那个容易被忽略的copy()
容器用得多了,复制就是绕不开的话题。copy.copy()做浅拷贝,新建外层容器,但内部元素还是原来的引用;copy.deepcopy()递归复制所有对象,完全独立。
浅拷贝对不可变元素没有影响,但对内部可变元素,“原容器和副本之间会互相影响”依然存在。比如:
new_config = copy.copy(old_config) new_config["dependencies"].append("new-mod")这会直接改动old_config里的列表。如果你不想让新配置污染旧配置,就得用deepcopy,或者手动复制内部的可变节点。
copy.deepcopy不是银弹,它递归复制所有内容,性能开销高,遇到循环引用还要依赖内部机制处理。在配置对象、缓存快照这类“整体替换”场景中用深拷贝是值得的,但在几十MB大数据结构上,每次请求都深拷贝一次,系统迟早被打垮。更好的办法是从设计上减少共享,比如用不可变类型作为内部字段,或者复制时只复制变了的那一层。
对于自定义容器,如果想让copy.copy行为符合预期,可以实现__copy__;要支持深拷贝,实现__deepcopy__。不实现的话,copy模块会尝试用默认方式复制,不一定符合你的类语义。
另外提醒一个和序列化相关的点:JSON只能直接序列化dict和list,tuple会被转成list,set会直接报TypeError。所以一旦涉及跨系统传输,先把set转成list,接收端再恢复set,这个转换本身也要写在代码里,而不是等到上线了由运维来发现。
讲了这么多,最后分享一个我自己坚持了很多年的小习惯:每次要选容器之前,先回答三个问题——数据需不需要保持顺序?元素或键需不需要唯一?这个操作是“写多读少”还是“读多写少”?答案出来之后,选型基本不会跑偏。也有人说,他写了两三年代码用list都没出问题,因为数据量太小,换成set反而更卡——这个观点我认同一半,容器选型确实要结合数据规模,不能拿复杂度公式生搬硬套。但真正的进阶,不是记住一堆API,而是知道每个容器在内存和速度上的取舍,然后带着明确的需求去选择。这层地基打牢之后,再看那些高性能代码,很多看似“巧妙”的写法,其实都只是容器知识的自然应用。