
我用Python写了十年代码发现最容易让新手在基础阶段就卡住的概念列表与元组绝对是其中之一。明明看起来都是方括号或者圆括号里装一串数据都能索引、切片、遍历可真到了写业务代码、做接口返回、存配置数据的时候选错类型导致返工的情况太常见了。这篇就把列表和元组掰开了讲清楚它们到底差在哪、底层怎么实现的、什么时候该用哪个、有哪些坑最好提前知道。如果你刚学Python或者写了一段但一直没搞明白两者取舍这篇应该能帮你在半小时内彻底理顺。1. 列表和元组到底在解决什么问题1.1 一句话版本可变与不可变列表和元组最本质的区别就一句话列表是可变的元组是不可变的。用大白话说可变就是创建完还能改能往里面加元素、删元素、改某个位置的值lst [1, 2, 3] lst.append(4) # 变成 [1, 2, 3, 4] lst[0] 100 # 变成 [100, 2, 3, 4]元组创建完之后不能新增、不能删除、不能替换元素tup (1, 2, 3) tup[0] 100 # TypeError: tuple object does not support item assignment很多资料爱用“列表是动态的、元组是静态的”来描述这个说法更贴近底层但对新手来说“可变与不可变”四个字才是真正理解一切后续差异的总开关。你可能觉得这区别也太简单了实际上从这一个小小差异出发能引申出内存占用、性能、能否做字典键、能否安全跨函数传递等一系列连锁反应。后面的内容全是围绕这四个字展开的。我见过不少同学用列表写一切连固定不变的配置项都塞在列表里理由就是“列表什么都能干元组还要多记一个圆括号语法多麻烦”。这个想法短期看没问题长期看会错过元组带来的很多结构上的保障。1.2 为什么Python要同时保留两种“序列表”既然列表能覆盖元组的功能Python为什么还要单独设计一个元组这就要聊到设计哲学了。Python的设计者有个知名的理念面对不用的场景应该用不同的工具来显式表达意图。列表和元组看起来都能存一串值但它们代表的语义完全不同。列表代表的是“一组相同性质、数量可能会变、顺序有意义的数据”。比如一个待办事项列表、一批用户ID、一段日志记录这些数据的特点是数量不确定今天5个明天500个而且你大概率要对它做筛选、追加、删除这些动态操作。元组代表的是“一组结构固定、字段含义各不同的数据”。比如一个坐标点(x, y)、一个RGB颜色(255, 128, 0)、一个接口返回的(状态码, 消息)。这些数据的特点是数量和每个字段的位置约定好了不应该随便变。你用status, message result去解包的时候脑子里的假设是“第一个字段是状态码第二个字段是消息”这种结构上的契约感只有元组能传递出来。换句话说列表是“容器”强调你能往里面装多少元组是“记录”强调里面每一项的定位和含义。这个区分不是写文档的人硬编出来的而是语言设计者有意为之。元组的不可变性让它特别适合当一个固定记录的载体创建出来就锁死谁拿到都不能偷偷改掉某个字段心里踏实。2. 核心细节解析底层实现与性能真相2.1 底层结构列表是动态数组元组是固定记录说完了抽象层面再看看CPython最常用的Python解释器底层是怎么实现这两者的。理解这一层你就能解释很多表面看起来“奇怪”的现象比如为什么列表比元组占内存、为什么列表的追加操作偶尔会变慢。列表在底层是一个动态数组也就是一连串连续的内存地址每个地址存放一个指向对象的引用。关键点在于Python不会老老实实地只给当前元素数量的空间而是采用“按需扩容”的策略。当你用append往里加元素时如果当前剩余容量不够解释器会重新申请一块更大的内存通常比例是原来容量的1.125倍左右然后把老数据整体复制过去。听起来有点浪费但这是空间换时间的经典做法多分配一点空间换来大部分append操作都是O(1)的快速追加只有扩容时才会触发一次O(n)的复制。这正是列表能高效支持动态增删的代价它必须允许自己“长得比实际内容大一点”。元组的底层也是一个连续的内存数组但它从一开始就一次性分配好所有需要的空间里面放着固定数量的对象引用。结构非常简单没有任何预留空间也不存在后续扩容的逻辑。创建完这个结构就定型了。因为不需要额外预留、不需要维护长度之外的其他状态它的内存布局天然就比列表紧凑。同样在底层列表和元组在对元素引用的持有方式上很多细节是一致的两者都只保存引用并不复制元素本身。所以一个列表里塞了五个大对象列表本身其实只存了五个“指针”而已。这也是为什么列表复制时用copy.copy和copy.deepcopy结果天差地别的原因浅拷贝只复制外层容器深拷贝才会连元素一起递归复制。2.2 性能测试别再信“元组一定更快”这句片面的说法很多文章喜欢斩钉截铁地说“元组比列表快”然后列一堆测试数据。这句话说得太绝对了它只在一定范围内成立。我实际写测试很容易就会发现快慢完全取决于你比的是哪一步操作。比如比较两者的创建速度元组确实更快因为它省掉了动态扩容的检查和预分配逻辑import time # 创建100万个元素的元组 t0 time.perf_counter() for _ in range(100_000): t tuple(range(20)) t1 time.perf_counter() print(tuple创建耗时:, t1 - t0) # 创建100万个元素的列表 t0 time.perf_counter() for _ in range(100_000): l list(range(20)) t1 time.perf_counter() print(list创建耗时:, t1 - t0)在我本机这个简单测试里元组创建大概比列表快15%到30%。原因很好理解列表要反复检查容量、触发扩容元组只做一次定型分配。但如果你比较的是索引访问、遍历、切片这些操作两者几乎没有差别因为它们底层都是对连续数组的常规访问CPU缓存友好度都一样。再比如比较append和sort这种列表特有的操作元组压根没有这时候谈谁快就没什么意义了。还有一个点经常被忽略随着元素数量的增加元组创建上的优势会被逐渐摊薄。因为分配空间的总量变大了一次分配固定大内存和动态扩容之间的差距比例在缩小尤其当数据量大到一定程度瓶颈已经不在“可变不可变”上而在内存带宽和GC开销上了。所以“元组一定快”这句话只适合作为开场白来给出一个感性认识真正做决定时不要把它当成硬指标。性能测试的最主要价值是让我们心里有数绝大多数业务代码里列表和元组的性能差异都处在“差不多”的范围内不值得为了5%的创建速度差距去牺牲代码可读性。下面这段是在我机器上跑出来的大致趋势不同的Python版本和执行环境会有差别但整体方向稳定。2.3 内存占用为什么元组更“瘦”用sys.getsizeof可以直接看到两种类型的内存开销差异import sys l [1, 2, 3, 4, 5] t (1, 2, 3, 4, 5) print(sys.getsizeof(l)) # 我本机输出是 120 print(sys.getsizeof(t)) # 我本机输出是 80同样装5个整数列表比元组多了40字节。这40字节去了哪一部分是列表为未来追加操作预分配的空间另一部分是列表对象本身的元数据、扩容状态等额外结构。Python 3.8以后列表的初始化策略在不同版本里有调整但整体趋势一致列表的“备用容量”让它在刚创建时比元组更占地方。不过要注意getsizeof显示的只是外层容器的大小不包含元素本身。当你关注内存时真正的大头永远是对象本体而不是容器外壳。假如你有一个存10万个字典的列表和一个存10万个字典的元组差异主要是容器本身那一层约几十字节乘十几万也就几兆字节和字典本身占的内存相比不算悬殊。理解这个能让你在分析内存问题时抓住主要矛盾不要为了省那一层容器的空间把本来该用列表的场景硬改成元组收益太小。还有一点我印象很深列表的“扩容后不缩容”策略。当你往列表里加了大量元素后来又把它们全部删掉Python不会自动把内存归还到和空列表一样的状态它可能依然保留着曾经扩容到很大的那块缓冲区域。这种设计是为了避免频繁申请释放碎片化但代价是“历史上大过”的列表会持续占据较多内存。元组没有这个问题它的大小自始至终固定这也是某些长生命周期缓存场景里元组更讨喜的原因。3. 实操指南什么时候用列表什么时候用元组3.1 该用列表的地方数量会变化的数据集合实际工程里大多数“一串数据”都应该用列表。判断标准很简单你之后会不会往里面加东西、删东西、改某个位置的值只要有这三种操作里的任何一种列表基本就是默认选择。举个具体的业务场景。假设你在做一个后台任务队列数据源会不停地产生新的任务项你需要把它们攒起来等攒到一批再批量处理。这个需求天生是动态的元素数量无上限还要频繁执行追加和清空。这种场景你不可能用元组因为元组创建后长度就定死了你总不能每来一个新任务就创建一个更大的元组再复制过去。再比如数据处理管线里常见的“中间结果收集”。你先从库里捞出来一批用户ID然后要逐个过滤、清洗、去重最后生成结果列表。这个过程中每一步都可能产生新数据、丢弃旧数据。列表的append、extend、remove这些方法就是为这种场景量身定制的。写起来也非常自然user_ids [] for raw in raw_data: uid clean(raw) if uid and uid not in user_ids: user_ids.append(uid)列表还支持原地排序、切片赋值等元组完全做不到的操作。有一点值得留意列表的sort()是原地排序会改变列表本身而内置函数sorted()返回的是一个新列表。新手常在这里绕晕l l.sort()这种写法会把l变成None因为sort()修改原列表并返回None。这也是“可变”特性带来的一个常见坑。我自己写代码的习惯是只要一个变量的名字在语义上带有“集合”“批”“队列”这类复数意味并且生命周期里存在增删变化我就用列表。比如pending_tasks、batch_records、search_tags。这种命名加上类型选择的组合能让代码读起来特别顺同事接手时不用琢磨你到底想表达什么。3.2 该用元组的地方固定结构、字典键、函数返回元组的用武之地在于“结构固定”这四个字。什么时候你会觉得“结构固定”想一想坐标、配置项、表格的一行记录这些数据每一项的位置都有约定数量也确定下来。把它们放进列表反而会误导别人好像之后会有增删一样。最常见的一个用途是作为字典的键。Python要求字典的键必须是可哈希的而列表因为可变哈希值不稳定所以不能当键元组因为不可变可以安全地做哈希也就成了组合键的默认选择。比如你要用一个二维坐标作为键存每个点位的温度值temp_map { (10, 20): 26.5, (30, 40): 28.1 } print(temp_map[(10, 20)]) # 26.5如果没有元组这种组合键就只能退化成拼接字符串或者嵌套字典写起来别扭不说还容易出错。另外元组也经常用来表示一组不变的环境配置比如(host, port, timeout)固定三项解包时非常清晰config (192.168.1.10, 8080, 30) host, port, timeout config函数返回多个值时默认用的就是元组。你写return user_id, status, message其实本质是return (user_id, status, message)。这种情况下用元组是对的选择它传递的是“这三个值是一次性结果”的语义而不是一个可以继续操作的集合。很多经验不足的开发者会把函数返回的元组再拿去赋值给一个变量然后试图修改它结果得到错误提示才会意识到这里本来就不是为了改而设计的。还有一类数据非常适合元组字段数据库里的一行记录。查询出来的结果集每一行都是一个固定长度的记录字段位置和字段数量由SQL决定不会今天多一列明天少一列。这种数据用元组表示再合适不过量一多还能省内存。很多ORM和数据库驱动底层就偏爱用元组返回查询结果这也解释了为什么你在用某些数据库库时取出来是元组列表。3.3 解包、星号表达式与命名元组元组的隐藏技能要真正用好元组不能只靠“它是不可变列表”这一个认识还得掌握几个和它高度相关的语法特性。首先是元组解包。Python里a, b (1, 2)这种写法几乎是条件反射级别的操作它比用下标data[0]、data[1]可读性强太多。解包可以和星号表达式搭配使用处理“前面固定字段 剩余不定数量”的结构。比如处理一长串数据我们约定第一个是名字最后两个是起止时间中间全是样本点data (sensor_01, 12, 15, 18, 21, 24, 08:00:00, 20:00:00) name, *samples, start_time, end_time data这个特性写起来很优雅尤其在一些格式化输出或参数解析的场景里能少写一堆切片。注意星号表达式只能出现一次否则解释器无法决定从哪里截断。这是很多人在稍复杂解包里报错的常见原因。另外元组和*运算符配合可以快速展开传给函数。比如你有一个坐标点p (3, 4)要调用draw(x, y)直接draw(*p)就行。这和**展开字典给关键字参数是一个姊妹用法。在写接口适配层时这两个操作能省掉大量样板代码。最后值得一提的加强版是namedtuple。Python标准库的collections.namedtuple允许你定义一个带字段名的元组类型。它保留元组的不可变、紧凑、可哈希等全部优点同时让每个字段有了名字访问时不再靠数字下标from collections import namedtuple Point namedtuple(Point, [x, y]) p Point(10, 20) print(p.x, p.y) # 10 20在需要大量创建“记录型”数据又不想为此定义一个完整类的场景里namedtuple是介于普通元组和自定义类之间的绝佳选择。它写起来接近定义类使用起来接近访问对象属性底层仍是元组。搭配_replace()方法可以生成修改某个字段的新实例从而在保持“不可变”的前提下实现部分更新。不过它也有短板后续版本中的typing.NamedTuple和dataclasses.dataclass在某些场景逐渐取代了它但了解它的存在仍然是理解Python数据建模的重要一环。4. 常见问题与避坑实录4.1 坑一元组里的列表到底算不算修改元组这是几乎每个Python初学者都会踩一次的问题。看下面的代码t (1, 2, [a, b]) t[2].append(c) print(t) # (1, 2, [a, b, c])很多人一脸懵元组不是不可变吗为什么能往里塞东西答案是元组的不可变指的是“它保存的那个对象引用不能变”而不是“它引用的对象内部不能变”。t[2]指向的永远还是那个列表对象这一点从未变过变的是那个列表自己内部的元素。这个概念我用最直白的话解释过很多次元组像是保存了一组写着“目标地址”的纸条纸条本身不能换但纸上的地址指向的房子里摆了什么家具元组管不着。如果你希望“深度不可变”只有两种办法一是别在元组里放任何可变对象这是最实际的二是用copy.deepcopy构造自己的不可变结构成本偏高很少用。这个坑容易引发连锁问题。比如有人把元组当作字典的键结果元组里藏着一个列表一旦这个列表被修改整个键的哈希值就变了字典查找会直接失败key (1, [2]) d {key: value} # TypeError: unhashable type: list好在Python很聪明当元组里包含不可哈希对象时计算哈希直接报错而不是等到后面才出诡异问题。这个报错其实是保护机制你要感谢它不许你把一个随时会变的键放进字典里。4.2 坑二可变对象作默认参数函数会“记住”上一次调用Python的默认参数是在函数定义时就被求值一次之后每次调用如果没传该参数用的都是同一个对象。如果你把默认参数设成一个列表这个列表就变成了全局共享的可变状态。def add_item(item, container[]): container.append(item) return container print(add_item(a)) # [a] print(add_item(b)) # [a, b]不符合大部分人的预期这个问题的根源还是“列表是可变对象”。解决方式很多资料都会写默认参数用None在函数内部创建一个新列表def add_item(item, containerNone): if container is None: container [] container.append(item) return container每次不传container时都生成一个新列表行为就符合直觉了。这个坑出现频率极高哪怕有经验的人也偶尔会犯因为它写起来实在太顺手了。建议把“默认参数里不写可变对象”当成一道肌肉记忆式的纪律碰到字典、集合、自定义对象同样适用。4.3 坑三初始化时的括号陷阱元组的字面量用小括号这让它和数学表达式产生了纠缠不清的关系。(1)在Python里是一个整数不是元组因为小括号在这里只起到了分组作用。要创建单元素元组必须写成(1,)。这个逗号才是元组的标志不是括号。a (1) print(type(a)) # class int b (1,) print(type(b)) # class tuple类似的陷阱还有()它是空元组而[]是空列表tuple()也会返回空元组。写代码时看到x (1),这种极端情况下能用一个带括号的表达式生成元组但没人会这么写这里就不展开了。在需要生成“有一个元素的列表”的初始化时新手也经常写出奇怪的东西比如[1,]这其实是一个列表因为逗号也允许出现在列表字面量里。要分清楚一个元素后加不加逗号影响的是它到底是元组还是普通表达式的结果这个全靠逗号来区分。建议记一个自检口诀看到小括号先别下结论是元组先看里面有没有逗号看到单个值反手加上逗号基本就不会在单元素这里翻车。4.4 坑四把“元组更快”奉为圭臬导致选型失衡网上流传的“元组性能远高于列表”的说法让一部分人养成了什么数据都用元组存的习惯。我见过一个真实案例某团队把一个会动态增长的采集队列写成了元组每次来新数据就old_data (new,)看着好像用了“不可变”的安心感结果性能差得离谱因为每次“追加”都要重新创建整个元组并复制全部元素复杂度从列表的O(1)摊还直接变成O(n)。如果你发现一个变量在反复做“拼新元组”或者“解包之后再重新组装”的操作那大概率应该回头换成列表。我之前有个线上服务每月保存一次配置快照为了让快照不可变代码里每改一次配置就生成一个新元组直接导致底层内存飙升。后来我把“原始配置项”用元组保存但“动态调整过程”用列表保存最后再转成元组落库问题立刻解决。这个案例教会我选型第一考虑的是数据生命周期里的操作模式第二才是性能。性能测试还有个常见误区拿小数据量反复测得出的结论对大数据量没有代表性。元组和列表在10个元素内的差异对你真实业务的性能影响几乎可以忽略不计。如果真想优化先做性能剖析找到真正的热点而不是凭感觉把列表改成元组。这个道理同样适用于其他所谓“更高性能”的替换方案。4.5 关于哈希可用的一个补充提示元组的可哈希性是有前提的元组里所有元素也都得可哈希。记住一个判断法则包含列表、字典、集合等可变容器的元组不能作为字典键或集合元素而包含普通数字、字符串、其他元组的元组则可以。你还可以用hash()直接测一下能返回整数就能用抛TypeError就不能用print(hash((1, 2, 3))) # 返回整数 print(hash((1, 2, [3]))) # TypeError这个点看似和“区别”没直接关系但它是元组不可变性的重要实际红利。很多库底层都会利用元组这一特性做缓存键、做去重集合。理解这一点后你再看代码里动不动把元组写进set()的场景就不会觉得奇怪了。最后聊点我自己的选型习惯。我负责过的很多项目里凡是配置类、坐标类、返回值类的东西我第一反应永远是元组凡是动态集合、结果集、队列、待处理清单我第一反应永远是列表。判断就那么几秒这东西创建之后还要不要增删改要列表不要元组。稳定压倒一切。比如函数里有个临时变量要存“分区ID采集周期”我直接写(zone_id, interval)返回时一并解包调用方拿到就能用谁也不许跑中间改这个结构大家都省心。踩过几次坑之后我最大的心得是别把它们当成“两种容器”而是当成“一种可变容器”和“一种不可变记录”。列表的灵活性和元组的安全性各有各的用途选错了类型不会立刻报错但会在代码维护、并发安全、接口约定这些地方以莫名其妙的方式给你找麻烦。希望这篇能让你少走一段我当年走过的弯路。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。