做Python开发这些年,被问到最多的基础问题里,“列表和元组到底怎么选”一定排得上号。很多入行两三年的同事能背出“列表可变、元组不可变”,但一旦真要在项目里定数据结构,还是会犹豫:到底什么时候用列表,什么时候用元组?只知道一个能改一个不能改,显然不够。今天这篇,我想把Python的列表与元组从“能不能改”这个表象,一路讲到它们在CPython底层的内存策略、性能差异,以及真实项目里那些值得拿捏的选型细节。
1. 从“能改”与“不能改”说起:列表和元组最本质的分水岭
1.1 不可变性到底是什么——引用级别的“锁”
先说最基础的定义:列表是可以原地增删改元素的容器,元组一旦创建,就不能再往里面添加、删除或者替换元素。我见过很多新手用t[0] = "new"去改元组,结果报错TypeError: 'tuple' object does not support item assignment,然后就记住了“元组不可变”。这个理解本身没错,但太过模糊,模糊到会在后面踩坑。
更准确的表述是:元组锁住的是“每个位置上的引用”。你可以把元组想成一排固定格子的储物柜,每个格子里放着一张纸条,纸条上写着一个对象的地址。所谓不可变,指的是你不能换掉某张纸条,不能把格子里的地址改成另一个对象。至于纸条指向的对象本身是什么状态,元组管不着。
举个例子:
t = ([1, 2], "a") t[0].append(3) print(t) # ([1, 2, 3], 'a')你看,元组里明明装的是列表,列表居然还能被修改。因为元组保证的是第二个格子里的引用没变——它还是指向同一个列表对象,列表对象内部新增了一个元素,这和元组的不可变规则完全不冲突。这一点必须刻在脑子里,因为很多人会误以为“元组不可变 = 里面所有东西都不能变”,这是第一节里面最容易产生的误区。
1.2 不可变与哈希:为什么列表不能当字典键
因为元组不可变,所以它的哈希值在生命周期内是稳定的,这也是它能当字典键的原因。列表不行,列表可以原地修改,如果把它当字典键,哈希值随时可能变,字典的查找逻辑就直接崩了。你试试:
d = {} d[[1, 2]] = "value" # TypeError: unhashable type: 'list'换个角度理解:Python的哈希要求“对象在哈希之后、生命周期之内,__hash__的返回值不能变”。元组的哈希值是根据内部元素的哈希值计算的,如果元组内部的所有元素都不可变,那么元组的哈希值就是确定的。一旦元组里塞了列表,不好意思,这个元组照样不能哈希:
t = ([1, 2], 3) hash(t) # TypeError: unhashable type: 'list'这其实和上一节是同一个坑:元组的不可变承诺被内部的可变元素打破了。所以严格说,不是“所有元组都能当字典键”,而是“所有元素都不可哈希的元组才能当字典键”。平时写代码,如果拿元组做缓存key,务必确认里面没有列表、字典这类可变对象。
2. 性能差异不是玄学:从内存分配到缓存复用
2.1 内存占用实测:列表为什么比元组“胖”
我曾经在讲解Python内存优化时,用sys.getsizeof直接量过列表和元组在64位CPython 3.11环境下的内存占用,结果非常直观:
import sys print(sys.getsizeof(())) # 40 print(sys.getsizeof([])) # 56 print(sys.getsizeof((1, 2, 3))) # 64 print(sys.getsizeof([1, 2, 3])) # 80 print(sys.getsizeof(tuple(range(100)))) # 856 print(sys.getsizeof(list(range(100)))) # 920元组基本是“对象头 + 元素指针数组”的紧凑结构,几个元素就存几个指针,不多不少。列表则要复杂一点:除了对象头和元素指针数组,它还会预留出一部分空余容量。为什么?因为列表要支持append操作,如果每次新加一个元素都重新申请内存,效率太低。所以CPython在列表扩容时采用的是一种“超额分配”的策略,大概思路是:当数组容量不足时,按new_size + new_size // 8 + 6这样的规则一次性多分配一部分空间,这样后续几次append就不需要再触发内存申请了。
配额多出来的这部分空间,平时你也察觉不到,len(lst)照样只统计真正有元素的数量,但对象整体的内存占用就上去了。数据量小的时候无所谓,可一旦要处理几十万、上百万条记录,列表多占用的那部分内存是实打实的成本,全部换成元组能省下不少。
2.2 创建与访问速度:差距藏在哪
内存之外,创建速度也有差异。我对比过同样内容的元组和列表,用timeit反复创建空容器或者单元素容器,元组通常快20%到40%。原因不神秘:元组的结构更简单,创建时直接申请一块连续内存放指针即可;列表创建后还要初始化一套可变容器的管理结构,并且在后续扩容时频繁分配新内存、搬运老元素。
更关键的在于CPython对元组有一种很激进的缓存优化:长度为1到20的元组被释放时,并不会立刻把内存还给操作系统,而是放进一个free list里缓存起来,下次再创建同样大小的元组时直接复用。列表也有类似的对象缓存,但它扩容时动态分配的“元素指针数组”逃不掉,整体缓存收益远不如元组。
访问速度的话,两者几乎没有可感知差别,毕竟底层都是“基地址 + 偏移量”的数组访问。所以性能问题通常出现在高频创建和销毁的场景,而不是高频读元素。你可以在循环里建几百万个小列表试试,改成元组后,运行时间会有可感知的下降;但如果只是遍历一个已经建好的大容器,谁快谁慢不用纠结。
3. 真实项目里的选型决策:什么时候用列表,什么时候用元组
3.1 元组的“安全契约”与不可变价值
我在项目里主要在这几类场景坚持用元组。
第一类是“这组数据本身就是一条静态记录”。比如坐标(x, y)、数据库查回来的一行数据(id, name, created_at)、颜色值(255, 255, 255)。这类数据的特征很统一:字段顺序有意义,字段个数固定,且不会往数据里追加新字段。用元组写起来轻,语义上也明确告诉后来者:这是一条不可变的记录,不是用来攒数据的集合。
第二类是“函数需要返回多个值时”。Python的函数返回多个值,本质就是打包成一个元组:
def get_config(): return "127.0.0.1", 8080 host, port = get_config()这里用元组是语言本身的机制,你也别想着改成返回列表然后拆包,虽然能跑,但语义上就变味了。
第三类是和字典键、集合元素相关的场景。缓存、去重、分组时,如果需要一个组合字段做key,元组是顺理成章的选择。比如统计每个用户的(user_id, level)组合出现的次数,用元组当Counter的键,非常方便。
第四类是函数形参里的*args。*args收集来的参数本质上就是一个元组,这个特性哪怕不常直接使用,也值得知道,因为有些代码会把*args拿去做遍历,其实是在遍历元组。
3.2 列表的“动态战场”
列表的核心优势就是可变性和丰富的方法集。需要频繁append、pop、insert、remove、排序、翻转的场景,列表是不二选择。比如从接口拉数据后边拉边往列表里塞,或者维护一个待处理队列,这种数据长度完全不可控、操作形态又极其动态的场景,你用元组是写不下去的。
列表还有一个隐性优势:它的元素可以替换。遇到“这一批数据里某一条要更新”的需求,比如记录用户当前会话状态的列表,你需要原地改某个位置的值,这时候列表就比元组顺手得多。元组是不可变对象,想改就只能重建一个新元组,代码写起来特别别扭。
3.3 一张选型对照表:结合类型提示
我整理了一张对照表,可以直接贴在项目文档里用:
| 维度 | 列表 | 元组 |
|---|---|---|
| 可变性 | 可变,可增删改元素 | 不可变,元素引用不能替换 |
| 哈希 | 不可哈希,不能当字典键 | 元素全可哈希时可当字典键 |
| 内存 | 偏大,有超额分配 | 偏小,紧凑结构 |
| 创建速度 | 较慢 | 较快,有小对象缓存 |
| 典型场景 | 动态数据集、队列、排序 | 静态记录、多返回值、字典键 |
| 类型提示 | list[int] | tuple[int, ...] |
在类型提示层面也有讲究。tuple[int, ...]表示“任意长度的整数元组”,tuple[int, int]表示“长度固定为2的整数元组”。列表则用list[int]。写类型注解的时候,这种差别也会反过来倒逼你思考数据结构的原始语义:到底是“数量不确定待处理的集合”还是“结构固定的记录”。
4. 切片、拆包与命名元组:进阶操作里容易忽略的细节
4.1 切片返回新对象的坑与用途
列表切片和元组切片都会返回一个新的容器对象,但这里是浅拷贝。也就是说,返回的新容器里装的是原容器中元素的引用,不是元素的深拷贝。这个机制在元组和列表身上都成立,但二维列表的坑特别典型:
mat = [[1, 2], [3, 4]] sub = mat[:] sub[0].append(100) print(mat) # [[1, 2, 100], [3, 4]]你以为mat[:]已经“复制”出独立的一份数据了,实际只复制了外层列表,内层列表还是同一批对象。如果项目里真要复制嵌套结构,得用copy.deepcopy。用元组做同样操作也是一样的道理,只是元组本身不可变,你可能不太会在上面做切片复制,但一旦元组里嵌套了列表,浅拷贝照样会出现内层被修改的问题。
4.2 星号拆包:*head、*tail 的优雅写法
列表和元组的拆包能力非常强,尤其星号表达式我几乎天天用:
head, *tail = [1, 2, 3, 4] print(head) # 1 print(tail) # [2, 3, 4]这行代码既适用于列表,也适用于元组。在处理不定长数据时,比用索引[0]和[1:]清晰得多。还有一个常见写法是只关心前几个字段,后面统一用一个变量接住,比如_, _, name, *rest = user_info。这里的_是占位符约定,表示这个位置的值我不要,写起来干净,读起来也舒服。
拆包时还有一个容易忽略的点:右边如果是字符串、迭代器,也可以拆包,但返回的rest默认是列表。这一点在数据清洗和日志解析时特别好用,不用手动list(...)转换,星号直接给你装成列表。
4.3 元组推导式不存在的真相
很多从列表推导式入门的朋友会想当然地写:
a = (x for x in range(5))以为这是元组推导式,结果发现a是个生成器对象。Python里没有元组推导式,圆括号加for表达式是生成器表达式,这也是很多初学者卡住的地方。想要得到元组,必须显式转换:
a = tuple(x for x in range(5))这个细节在一定程度上也反映了设计取向:元组是为了固定记录和不可变容器服务的,不是用来做“经过推导生成的新序列”的主流工具。如果你需要从已有数据生成一段新数据,又想保持不可变的特点,那就用tuple(...)包装生成器表达式,语义非常清晰。
4.4 命名元组:比普通元组更可读的记录类型
普通元组当记录用有一个麻烦:字段全靠索引访问,代码一长,row[0]、row[1]这种写法可读性很差,很容易把索引写错。我的建议是,一旦字段超过3个或者字段含义很重要,就不要用裸元组了,直接用namedtuple:
from collections import namedtuple Point = namedtuple("Point", ["x", "y"]) p = Point(x=1, y=2) print(p.x, p.y) # 1 2namedtuple本质是元组的子类,它保持了一切的不可变特性,还能当普通元组一样拆包、索引、比较大小、当字典键。同时它又提供了字段名访问,代码可读性上一个台阶。数据类这种“带名字的元组增强版”在配置管理、数据上报场景里非常实用。
5. 我在项目中踩过的坑:列表和元组的真实事故
5.1 默认参数用列表:经典bug
这是我见过最多、也最容易顺手写出来的一个坑:
def add_item(item, items=[]): items.append(item) return items print(add_item(1)) # [1] print(add_item(2)) # [1, 2],但很多人以为是 [2]原因在于函数定义时默认参数只会被求值一次,这个空列表是共享的。同一个列表对象被后续多次调用反复修改,最后数据全串了。元组作为默认参数就没有这个问题,因为不可变对象每次访问都是同一个东西,你也没办法在它上面做修改操作。教训是:默认参数里要用无参默认值,比如items=None,内部再判断初始化。
5.2 函数返回列表被外部误改
项目里有个函数返回一个缓存配置列表,调用方拿过去之后,有个同事顺手append了自己的一堆临时数据,结果下一次其他模块再来读这个“配置”时,发现配置里混入了一堆莫名其妙的内容。这属于典型的“可变对象被共享”事故。
如果当时用元组保存配置,就不会有这个悲剧。因为元组的不可变性带来了一种“安全契约”:调用方拿到手只能用,不能改。如果你要对它做扩展,就得新建一个容器,改动的影响范围直接被限制住。这其实是用元组的最大价值,它把一个设计层面的约定变成了编译器层面的强制执行。
5.3 元组里的列表给哈希带来的边界问题
有一段时间我在优化一个去重逻辑,用元组(user_id, tag_list)当集合元素,本以为元组可哈希,放进集合没问题。结果代码一跑就报TypeError: unhashable type: 'list'。排查发现元组里有个字段是列表,导致整个元组不可哈希。
这个问题的教训就是:用元组当key之前,一定检查元组内部的字段是不是全都能哈希。如果从一开始就确定某个字段需要作为可变集合存储,那就别硬塞进元组里,要么把列表转成元组,要么用冻结的不可变结构。这是一种需要主动建立的数据结构“体检意识”。
5.4 数据库返回的元组记录:索引访问的脆弱性
用sqlite3或大部分数据库驱动查询数据时,返回的每条记录本质上就是一个元组。我用这种裸元组写了很久的row[0]、row[2],有一天表结构加了字段,索引全乱了,改代码改到怀疑人生。
后来我统一改成用namedtuple或者字典接收查询结果。数据库驱动一般支持指定row_factory,把行元组包装成带名字的结构。这个改动成本很低,但能极大降低对“字段位置”的脆弱依赖。数据模型越复杂,越应该用语义明确的容器,裸列表裸元组适合快速原型,不适合长期维护的项目。
5.5 一个简单的内存优化实例
我之前处理过一个日志解析任务,约200万条原始日志,每条要拆成(timestamp, log_level, thread_name, message)四元组。最初用的都是列表,跑完之后进程内存峰值接近1.2GB,老被运维盯上。后来把记录改成元组,并把线程名做了字符串驻留(就是复用同一个字符串对象),内存降到800MB左右,解析速度也快了约15%。
这个优化之所以有效,一方面因为元组少了列表的超额分配,另一方面因为这种数据在落地之后不会再被修改,用列表是可变的也算不上什么优势,反而白白浪费内存。基础数据结构的选型,在数据量大的时候真的会变成运维层面的关键指标。
我个人现在写代码的默认习惯是:不确定要不要变的数据,先用元组;明确要增删改的数据,才用列表。这个习惯帮我避免了很多可变引起的连带bug,也让我在写复杂系统时思路更清晰。你如果刚开始学,不用急着背所有细节,先把“元组锁引用,不锁对象内容”“列表适合动态操作,元组适合静态记录”这两条记牢,后面遇到具体场景再逐步加深,就不会在这个最基础也最核心的选型问题上翻车。