1. 先搞清楚本质:列表和元组到底是什么
在 Python 里,列表和元组几乎是最常用的两种内置数据结构。很多新手学到这里都会觉得有点绕:都能存数据,都能下标访问,语法上就差一个方括号和圆括号,凭什么要分两种东西?这篇我直接用大白话把这两个家伙的底细扒清楚,包括它们的区别、各自的适用场景,以及我实际写代码时的一些取舍经验。
先说结论:列表是可变的,元组是不可变的。这句话看着简单,但它是理解一切区别的源头。
1.1 可变与不可变,是一切区别的根源
可变(mutable)和不可变(immutable)是什么意思?我用生活中最通俗的例子解释。
列表就好比你手里拿着一张购物清单,你可以随时在上面加东西、划掉东西、修改某一行。这份清单本身是你的一张纸,内容可以随便改,纸还是那张纸。
元组呢,更像你在咖啡馆下单后拿到的那张小票。打印出来是什么就是什么,你不能手动在小票上把“美式”改成“拿铁”再拿回去要求店员按这个做。小票的内容固定不变。
这个差异在内存层面怎么体现?当你创建一个列表,Python 给它分配一块内存,这块内存里存的是“指向各个元素的引用”的数组。当你往列表里 append 一个元素,它可能会扩展这块内存,把新元素的引用塞进去。当你修改list[0] = x,它只是把数组中的第一个引用指向了新的对象。列表对象本身的id()始终不变,变的只是它的内部内容。
元组的底层结构也是一个存放元素引用的数组,但是 Python 解释器明确保证了:一旦元组创建完成,你没有任何办法修改这个数组中任何一个槽位。没有 append、没有 extend、没有__setitem__、没有__delitem__。你只能通过重新赋值的方式让变量指向一个全新的元组,但原来的那个元组对象永远停留在创建那一刻的状态。
别小看这个“不可变”,它直接决定了元组可以被哈希、可以被放进集合、可以做字典的键。而列表因为内容会变,哈希值无法稳定,所以 Python 直接禁止它作为字典键。这就是为什么你写{ [1,2]: "a" }会直接报TypeError: unhashable type: 'list'。
1.2 元组不只是“不可变的列表”
很多人把元组理解成“不能修改的列表”,这个说法没错,但不够完整。元组在一些细节上跟列表有本质性差异,尤其是**解包(unpacking)**能力。
Python 的元组支持非常优雅的解包语法:
point = (3, 4) x, y = point列表同样支持解包:
point = [3, 4] x, y = point但元组在语义上天然就是“一组固定数量、各自有含义的值”,而列表在语义上更像“一堆同类型、数量不定的数据”。比如坐标(x, y)、RGB颜色(255, 0, 0)、一个人的(name, age, city),这些就是元组的主场。而一个班级所有学生的分数、一篇文章的所有单词,这些是列表的主场。
再深入一层,元组的不可变性还带来一个关键优势——它可以作为字典的键,也可以放进另一个集合里。比如你想用坐标点作为键来存储地图上每个点的温度:
temperatures = {} temperatures[(116.4, 39.9)] = 25 temperatures[(121.47, 31.23)] = 28如果 key 用列表,上面的代码直接报错。这一条规则在实际开发中非常常见,尤其是处理坐标、日期区间、组合键这种场景。
很多人还会忽视一点:元组的不可变是深度的还是浅度的?答案是浅度不可变。元组本身不能增删改元素,但如果元组里的某个元素本身是可变对象,比如列表或字典,那么这个可变对象的内部内容是可以被修改的。这一点我后面有一整个段落专门讲,因为它是新手最容易踩的坑之一。
2. 操作细节对比:从创建到切片
搞清楚概念后,我们来看实际操作。列表和元组在日常使用中的操作差异,主要集中在“修改类操作”和“性能表现”这两块。下面我按操作维度一一拆解。
2.1 创建和初始化
创建列表有三种常见方式:
# 方式一:字面量 lst = [1, 2, 3] # 方式二:list() 构造 lst = list(range(10)) # 方式三:列表推导式 lst = [x * x for x in range(10)]创建元组同样有三种对应方式:
# 方式一:字面量 tup = (1, 2, 3) # 方式二:tuple() 构造 tup = tuple(range(10)) # 方式三:生成器表达式 tup = tuple(x * x for x in range(10))这里有一个非常经典的陷阱:创建只含一个元素的元组时,括号会被当作优先级符号,导致你以为创建了元组,实际上只是生成了一个数字:
tup = (1) print(type(tup)) # <class 'int'>正确写法必须加一个逗号:
tup = (1,) print(type(tup)) # <class 'tuple'>其实不仅仅是单元素,任何元组字面量的最尾部那个逗号都可以省略,唯独单元素元组这个逗号是必须的。很多人写代码时会忘记,等到后面拿这个“元组”去遍历的时候才发现报错TypeError: 'int' object is not iterable。
2.2 修改、删除与切片
列表的增删改操作我不多讲,大家一般都比较熟悉:append、insert、pop、remove、del、extend,加上下标赋值。这些都是元组完全不具备的。
元组一旦创建,你试图执行任何修改操作都会直接AttributeError。
tup = (1, 2, 3) tup.append(4) # AttributeError: 'tuple' object has no attribute 'append' tup[0] = 99 # TypeError: 'tuple' object does not support item assignment del tup[0] # TypeError: 'tuple' object doesn't support item deletion但有一个很微妙的地方:元组可以和另一个元组用+拼接,得到一个新的元组,而原来的元组没有改变。
a = (1, 2) b = (3, 4) c = a + b # (1, 2, 3, 4) # a 还是 (1, 2),b 还是 (3, 4)如果你把a += b写出来,效果是:Python 先计算a + b,然后把结果赋给变量a。这个过程中创建了一个新元组,变量a指向了新的对象,原来的(1, 2)依然存在于内存中,直到被垃圾回收。所以在循环里不断写tup += (i,),本质上是在反复创建新对象,效率很低,这时不如用列表。
切片方面,列表和元组都能切,语法完全一样:
lst = [0, 1, 2, 3, 4] tup = (0, 1, 2, 3, 4) print(lst[1:3]) # [1, 2],切片结果是列表 print(tup[1:3]) # (1, 2),切片结果是元组注意切片返回的类型和原类型一致,这就是很多面试题里会出的点:对字符串切片返回字符串、对列表切片返回列表、对元组切片返回元组。切片不会改变原数据,不管是列表还是元组,都是新的对象。
2.3 遍历与解包
遍历这块,列表和元组的体验几乎一样,都可以用for item in seq直接迭代,也都可以用enumerate取索引和值。区别主要体现在“修改遍历中的元素”这个环节。
你无法在遍历元组时修改元素,但可以安全地读取。而遍历列表时如果想修改元素,推荐用索引遍历或者enumerate:
lst = [1, 2, 3, 4] for i in range(len(lst)): lst[i] = lst[i] * 2 # 结果是 [2, 4, 6, 8]或者:
for i, val in enumerate(lst): lst[i] = val * 2同时在遍历列表时,尽量不要在循环体内删除当前迭代的元素,这会导致索引错位。比如:
lst = [1, 2, 3, 4, 5] for i in lst: if i % 2 == 0: lst.remove(i)这个代码的结果不是你预期的[1, 3, 5],而是[1, 3, 4, 5]。因为remove在遍历过程中会改变列表的长度和内部索引,导致跳过某些元素。正确做法是先拷贝一份再遍历,或者直接用列表推导式。
解包是这两者最有魅力的地方,尤其是元组解包。Python 支持带星号的占位解包:
first, *rest = (1, 2, 3, 4) # first = 1, rest = [2, 3, 4]注意这个星号表达式的结果永远是一个列表,即使是从元组解包。这个细节在写代码时很实用,比如只取头尾元素:
head, *middle, tail = [1, 2, 3, 4, 5] # head = 1, middle = [2, 3, 4], tail = 52.4 嵌套与多维结构
列表和元组都可以嵌套,形成二维、三维乃至更高维的结构。
嵌套列表,典型比如二维矩阵:
matrix = [ [1, 2, 3], [4, 5, 6], [7, 8, 9] ]嵌套元组,典型比如多组坐标:
coords = ((1, 2), (3, 4), (5, 6))还有一种非常常见的混合嵌套:列表里的元素是元组,比如students = [("张三", 18), ("李四", 19)],或者元组里的元素是列表,比如config = (["a", "b"], ["c", "d"])。
这里面有个很有意思的点:元组里的元素是列表时,元组不能增删元素,但元组里那个列表的内容可以变。你必须明确认识到“元组的不可变”不等于“元组的所有东西都不可变”。
我用一个具体例子:
tup = ([1, 2], [3, 4]) tup[0].append(5) print(tup) # ([1, 2, 5], [3, 4])这个操作没有违反元组的不可变性,因为tup[0]这个槽位仍然指向同一个列表对象,只是那个列表的内部多了一个元素。重点在于:tup[0] = ...这种重新赋值的操作是被禁止的,但tup[0].append(...)这种对内部可变对象进行修改的操作是被允许的。
3. 什么时候该用哪个:场景实战
这是整篇文章的重头戏。理解了概念之后,最重要的就是决策:写代码时到底该用列表还是元组?
3.1 元组的典型应用场景
第一,用于表示固定结构、固定含义的一组数据。比如坐标点(x, y),一个三维颜色值(r, g, b),一个时间段(start_time, end_time)。这类数据有几个特点:字段数量固定、字段顺序有意义、一般不需要在中间插入或删除。用元组的语义非常清晰,读代码的人一看就知道这是“一个整体”,而不是“一组同类数据”。
第二,函数的多返回值。Python 函数返回多个值时,底层返回的其实就是一个元组:
def get_user(): name = "张三" age = 18 return name, age这里return name, age省略了括号,等价于return (name, age)。调用方直接name, age = get_user()完成解包。这种模式在 Python 里太常见了,很多标准库函数就是这么设计的。
第三,字典的键或集合的元素。这是元组不可变性的红利。如果你想把两个值合成一个键,比如坐标点(x, y)作为 key,元组是唯一可行方案(不考虑自定义类的情况下)。
第四,可哈希的数据结构需要。当你需要把一组数据放入set中做去重,且这组数据是固定结构时,元组是天然的好选择。比如:
seen = set() seen.add((1, 2)) seen.add((1, 2)) print(len(seen)) # 1如果试图把列表[1, 2]放进 set,同样直接报TypeError。
第五,性能敏感的场景。这个我后面单独用一整段讲,这里先给结论:在数据量非常大的情况下,元组比列表更快、更省内存。
第六,对数据安全有要求的场景。比如你的配置项不希望被某个函数意外修改,用元组可以防止误操作。这种场景在团队协作里尤其有价值,因为你没法控制其他同事写的代码会不会顺手把你的列表给append一下。
3.2 列表的典型应用场景
第一,元素数量动态变化的场景。比如一个不断收集用户输入的数据集,你无法预知会有多少条,只能用列表不断 append。
第二,需要频繁插入、删除、排序、倒序操作的场景。这些操作列表都有内置方法支持,元组完全无能为力。
第三,元素类型相同的批量数据。比如一个班级所有学生的成绩、一篇文章的所有行、一个文件夹里所有文件名。这类数据长度不固定,含义一致,天然适合列表。
第四,作为栈或队列使用。Python 的list.append()和list.pop()配合,可以很方便地实现栈。用collections.deque可以实现双端队列,但底层还是可变序列的逻辑。
第五,需要频繁遍历并且修改元素内容的场景。比如批量对数据进行清洗、转换、格式化,列表直接下标赋值就能完成。
3.3 一个实用的决策标准
我在实际写代码时有一个习惯,也是给新手的一条建议:先问自己三个问题——
这个数据集合的内容会不会变?如果永远不变,选元组。如果会变,选列表。
这个数据集合的长度是固定的吗?如果长度固定且每个位置的语义不同,选元组。如果长度不固定或者长度可能会变化,选列表。
这个数据集合需要作为字典的键或放进集合中吗?如果需要,只能选元组。如果不需要,两者都可以,再根据前两个问题判断。
这三个问题基本能覆盖绝大多数场景。原则很简单:能用元组的地方优先用元组,因为不可变性是天然的“防呆设计”,能减少意外修改带来的 bug。但如果你觉得别扭,不想动脑判断,那就用列表,Python 社区里默认用列表的人数绝对占多数,代码也更容易被人理解。
话说回来,不要为了炫技而刻意用元组。如果一个数据你最终要排序、要修改、要动态增删,用元组只会让你的代码又臭又长——每次修改都重新创建一个新元组,不如列表来得直接。
4. 性能与内存:元组到底快在哪
很多性能对比的文章都会告诉你“元组比列表快”,但不会告诉你具体快在哪里、快了百分之多少、什么情况下快。这里我用三个方向实测说明,并且给出判断依据。
4.1 CPU 访问时间的对比
在 CPython 中,元组和列表的底层结构很相似,都是变长的对象数组,每个元素都是一个PyObject*指针。访问元素时,逻辑几乎一样,都是根据索引偏移去取指针。
但有一个微小差异:列表在迭代时需要进行“列表是否被修改”的检查,因为如果你在迭代列表的过程中删除了元素,迭代器需要感知到这个变化并抛出RuntimeError。这个检查在每次迭代时都会触发,而元组不可变,编译器可以省掉这个检查。这导致在大规模循环中,元组的迭代会快一点点。
实际测试中,这个差异一般来说在数千万级别的循环里才能显现出肉眼可见的差距,普通业务代码里几乎感受不到。所以如果你选元组是为了“更快地遍历几万个元素”,说实话收益很低,反而是“避免误修改”这个收益更实在。
4.2 内存占用的对比
这一块差异更明显。列表在创建后,如果不断 append 元素,Python 会预分配多余的内存空间,以便后续扩展时不至于频繁重新分配内存。这就是列表内存被“预分配”掉了,你的 5 个元素可能占了 8 个元素的内存空间。
元组没有这个机制。元组在创建时就精确分配了容纳指定数量元素的内存,不多不少。这个差异在元素数量很大或者元组数量很多时非常明显。
我写过一个简单测试:
import sys lst = list(range(1000)) tup = tuple(range(1000)) print(sys.getsizeof(lst)) # 通常 8440 左右 print(sys.getsizeof(tup)) # 通常 8040 左右差距不算离谱,但当一个程序里有上百万个元组/列表对象时,差额就会急剧放大。数据越多,元组省下的内存越可观。
另外,Python 对元组还有一个缓存机制:当某个元组不再被使用、被垃圾回收后,如果它的大小不是特别大,CPython 会把这个元组的内存块缓存起来,供后续创建相同大小的元组直接复用。这个机制让元组的创建和销毁在某些场景下比列表更快。
4.3 为什么元组能作为字典键而列表不能,性能上的考量
我们前面说了,列表无法哈希,无法作为字典键。这个规则的底层逻辑仍然和“可变性”强相关。
如果允许可变对象作为字典键,那么当你往列表里加了一个元素后,它的哈希值就应该变化,但字典在插入时已经把它放在了某个哈希桶里。下次查找时,哈希值变了,会到完全不同的桶里找,结果找不到,或者找到错误的值。这会导致字典的数据结构彻底崩溃。
元组不存在这个问题。因为它的内容不会变,哈希值一旦确定就永远稳定。这也意味着,元组的哈希值是可以在创建时就计算好的,Python 内部会缓存这个结果,后续访问hash(tup)时直接返回缓存,耗时几乎为零。
tup = (1, 2, 3) print(hash(tup)) # 立刻返回,无需重算这对大量使用元组作为键的程序来说,是一个隐性的性能优势。
5. 常见误区和避坑经验汇总
从我自己带新人的经验看,列表和元组这个知识点看起来简单,坑却不少。下面汇总几个特别典型的误区,每一个我都见过真实翻车案例。
5.1 误区一:元组里的列表不会被修改
前面已经反复强调过了,元组是不可变的,但元组内元素的内部内容不受此限制。你写:
config = (["admin", "user"], ["read", "write"]) config[0].append("guest") # 这个操作是允许的很多刚学 Python 的人以为元组一锁全锁,实际上元组只锁“引用”,不锁“引用所指的内容”。如果真要彻底不可变,需要自定义不可变容器或者使用嵌套的深层元组。
这个特性在安全相关的代码里也是一个需要警惕的点:元组并不能保护你的数据不被内部对象修改。如果你的数据安全完全依赖元组,可能会被钻空子。
5.2 误区二:单元素元组忘写逗号
这个坑真的几乎人人都踩过。千万记住,(1)不是元组,(1,)才是。更隐蔽的是,元组打印出来时逗号总是存在,但创建时很容易漏掉。
我见过一个实际案例,某同事从配置文件解析了一个单元素列表,转元组后拿去当数据库查询参数,一直报int too many arguments。排查了半小时,最后发现是tuple([1])得到的是(1,)而不是他以为的(1)。一开始他以为tuple([1])会失败,结果没有失败,只是后面参数展开时出了问题。
5.3 误区三:对循环中元素的修改“没有生效”
看这段代码:
lst = [[1, 2], [3, 4]] for item in lst: item = [9, 9] print(lst) # 仍然是 [[1, 2], [3, 4]],因为 item = [9, 9] 只是改变了局部变量指向这在列表和元组中都会遇到,因为item = ...是给局部变量重新绑定一个新对象,没有影响原序列。但下面这个写法是生效的:
lst = [[1, 2], [3, 4]] for item in lst: item.append(9) print(lst) # [[1, 2, 9], [3, 4, 9]]这会直接修改原列表中的嵌套列表。
这两者的区别,本质上是“重新绑定引用”和“修改引用所指对象的内容”的区别。初学者一定要在头脑里建立这个模型,否则很多莫名其妙的问题都找不到根源。
5.4 误区四:在循环里用元组做累积拼接
有同事写过去重逻辑,思路是遍历一批数据,符合条件的元素不断tup += (item,)加到元组里。数据量少的时候还行,数据量大就开始卡顿,因为每次+=都会创建一个全新的元组对象,旧的元组被丢弃,反复分配内存和复制数据,复杂度接近 O(n²)。
正确的做法是用列表收集,最后如果需要元组再tuple(lst)一次性转换。
result_lst = [] for item in source_data: if condition(item): result_lst.append(item) result_tup = tuple(result_lst)5.5 误区五:混淆==与is
元组和列表在比较时有一点容易被忽视:==比较的是内容是否相等,is比较的是是否是同一个对象。
a = [1, 2, 3] b = [1, 2, 3] print(a == b) # True print(a is b) # False对于相同内容的两个不同列表,==是 True。元组也一样,两个内容相同的元组==也是 True。
但这里有一个 Python 解释器的优化细节:对于不可变的小整数、短字符串和某些简单的元组,CPython 可能做了 intern 或者常量折叠,导致a is b可能返回 True。这个行为依赖于具体值的长度和解释器版本,不能当作可靠逻辑。
x = (1, 2) y = (1, 2) print(x is y) # 某些环境下可能 True,但不要依赖这种“随机性”如果被写进业务代码,会造成非常隐蔽的逻辑 bug。
5.6 误区六:从 Excel 或数据库读取数据后,疯狂改列结构
很多做数据处理的人会遇到这种情况:从数据库读取一行数据,默认拿到的是一个元组。这时如果你直接试图追加一列、修改某个字段,就会报错。新手会觉得元组太不灵活,转而把所有行都转成列表。
我的建议是:不要一上来就转列表。先确认原始数据是否需要修改。如果只是读取、传递给其他函数、作为不可变记录持久化,元组就非常好,还能顺便防止下游代码不小心改动数据。如果确实需要增删列,再list(row)转成列表,或者用 NamedTuple 这种更高级的数据结构。
5.7 简单速查表
我整理这张表,方便日常写代码时对照:
| 对比维度 | 列表 list | 元组 tuple |
|---|---|---|
| 创建语法 | [1, 2, 3] | (1, 2, 3) |
| 可变性 | 可变 | 不可变 |
| 可哈希 | 否 | 是(元素也必须可哈希) |
| 用作字典键 | 不行 | 可以 |
| 动态增删元素 | 支持 | 不支持 |
| 排序、反转 | 可以原地操作 | 只能新建排序结果 |
| 内存占用 | 高(有预分配) | 低(精确分配) |
| 迭代速度 | 稍慢 | 稍快(实际差距微弱) |
| 典型用途 | 动态数据集、批量操作 | 固定结构、函数多返回值、字典键 |
6. 一些进阶的实战小技巧
这里说几个我自己在工作中经常用到的操作,都是列表和元组搭配才能发挥最大威力的写法。
6.1 用*来拆解可迭代对象
不仅函数参数可以用*args,普通赋值和解包也能用*。比如你要把一个列表和一个元组合并:
lst = [1, 2] tup = (3, 4) combined = [*lst, *tup] # [1, 2, 3, 4]这个写法的好处是直观、不用写循环,而且在处理混合结构时特别优雅。
6.2 将列表转换成元组实现参数保护
你写了一个函数,接收用户传入的配置项,但你不希望函数内部意外修改用户的原始数据列表。可以在函数入口加一行config = tuple(config)。
def process_config(config): config = tuple(config) # 保护后续代码不会意外修改 # 后续逻辑这是一种非常轻量级的防御性编程手法。
6.3 使用collections.namedtuple
如果你喜欢元组的不可变性,又希望字段能直接通过名字访问,可以升级到namedtuple或者dataclass。
from collections import namedtuple Point = namedtuple('Point', ['x', 'y']) p = Point(3, 4) print(p.x) # 3 print(p.y) # 4 p.x = 5 # AttributeError,不可修改namedtuple在返回值语义清晰、又不需要写一个完整类的时候特别好用。特别是代码在多个函数之间传递一组固定字段的数据时,比直接返回元组然后靠位置猜字段要安全得多。
6.4 注意可变默认参数陷阱
这虽然不是列表和元组直接对比的问题,但非常常见:在函数定义中使用可变对象作为默认参数,会导致所有调用共享同一个对象。
def add_item(item, lst=[]): lst.append(item) return lst多次调用会累积,因为默认列表在函数定义时就被创建了,每次调用都往同一份列表里追加。如果用元组默认值就不会有这个问题,因为元组不可变。这个例子反过来印证了元组的不可变性在 API 设计中的价值。
7. 最后的建议
列表和元组的选择,没有绝对的对错,但有“更合适”和“更别扭”的区别。原则就是:能确定不变的数据,优先用元组;需要动态操作的数据,别犹豫,用列表。
我从实际项目里得到的体会是:元组的不可变性质不光是语法层面的限制,更是一种代码意图的表达——你用元组,就是在告诉读代码的人“这段数据是一组具有固定结构的信息,我不希望任何人随意改动它”。这种表达能力在团队协作中尤其珍贵,减少了大量因为“不知道哪个函数偷偷改了数据”而耗费的排查时间。
初期如果你拿不准,就记住一句话:改不动的东西用元组,改得动的东西用列表。把这两种基础数据结构用顺了,后面学字典、集合、列表推导式都会顺畅很多。