
做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个或者字段含义很重要就不要用裸元组了直接用namedtuplefrom collections import namedtuple Point namedtuple(Point, [x, y]) p Point(x1, y2) 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]原因在于函数定义时默认参数只会被求值一次这个空列表是共享的。同一个列表对象被后续多次调用反复修改最后数据全串了。元组作为默认参数就没有这个问题因为不可变对象每次访问都是同一个东西你也没办法在它上面做修改操作。教训是默认参数里要用无参默认值比如itemsNone内部再判断初始化。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也让我在写复杂系统时思路更清晰。你如果刚开始学不用急着背所有细节先把“元组锁引用不锁对象内容”“列表适合动态操作元组适合静态记录”这两条记牢后面遇到具体场景再逐步加深就不会在这个最基础也最核心的选型问题上翻车。