资讯动态

Python列表与元组:可变性、性能与选型全解析

发布时间:2026/10/14 11:53:20 来源:尧图企业网站定制
做Python这几年我面试过不少人也在不少代码评审里见过类似争论列表和元组到底有什么区别有人脱口而出“列表能改元组不能改”然后没了有人会补一句“元组快一点”但也说不出为什么快、快多少、什么场景该用谁。其实这个问题远不止“能不能改”这么简单它牵扯到内存分配、哈希特性、函数返回值传递、解包语法甚至还能牵出命名元组和不可变设计哲学。适合谁看刚开始学Python的初学者可以把这块彻底打通有一定经验的开发者可以对照自己代码里的选型习惯看看有没有踩坑。今天我把这两个内置容器从头到尾捋一遍从底层机制到实操建议全部摊开讲。1. 先从最本质的差异说起能不能改1.1 列表可变元组不可变这句话的完整含义“列表可变元组不可变”这句话正确的理解不是“列表能append元组没有append”这么简单而是指列表对象的内存在创建后可以改变大小元素指向也可以重新绑定元组对象一旦创建其长度、元素顺序、每个槽位指向的对象引用都固定了。看一个最直观的例子lst [1, 2, 3] lst[0] 100 lst.append(4) print(lst) # [100, 2, 3, 4] tup (1, 2, 3) # tup[0] 100 # TypeError: tuple object does not support item assignment # tup.append(4) # AttributeError: tuple object has no attribute append列表的赋值语法lst[0] 100本质上是修改了第一个槽位里的引用让它指向另一个对象100。元组不允许这种槽位引用被重新绑定所以直接报TypeError。这里有一个关键点很多人会误解元组不可变说的是“槽位引用不可变”不是说“槽位里指向的那个对象不能变”。假如元组里放了一个列表列表本身的内容还是可以改的tup ([1, 2], hello) tup[0].append(3) print(tup) # ([1, 2, 3], hello)这一点在本文第4部分还会专门展开因为它常常是笔试题和实际bug的高发区。1.2 可变性带来的一系列连锁差异可变性不只是“能改不能改”它会连锁影响到很多东西。第一方法数量差很多。列表因为可变有一堆修改自身的方法append、insert、pop、remove、clear、extend、sort、reverse。元组没有这些它只有count、index这类查询方法以及、*这类会返回新对象的操作。第二可变对象不能直接做字典的键也不能放进集合。因为字典和集合依赖哈希值来定位元素而哈希值必须基于一个不变的状态去计算。如果键是可变的哈希值算完一次之后内容又变了整个数据结构就乱了。所以只有元组这类不可变对象才能可靠地被哈希d {(1, 2): a} # 正常 # d {[1, 2]: a} # TypeError: unhashable type: list第三可变性影响函数参数的传递体验。把列表传给函数函数内部改了元素外面也会跟着变想避免这种副作用就得用copy或[:]复制一份。元组传进去就不用担心这个因为反正改不了。很多时候我们用元组传参就是为了向调用方传递一个“我不想修改你的数据”的信号。第四可变性还影响浅拷贝和深拷贝的复杂度。列表可以有嵌套列表拷贝时稍不注意就是“改了复制品原列表也变了”。元组即便嵌套也因为外层不可变至少保护了顶层结构。2. 性能与内存元组凭什么更轻量2.1 内存占用实测在Python里列表因为要支持动态扩容会预先分配一块比当前元素数更大的内存元组则精确分配刚好能装下所有元素的连续内存不做任何预留。我用sys.getsizeof实测一下import sys lst [0, 1, 2, 3] tup (0, 1, 2, 3) print(sys.getsizeof(lst)) # 8864位Python 3.x环境 print(sys.getsizeof(tup)) # 72列表多出来的那部分字节不是存储元素本身而是维护“扩容余量”和额外结构。随着不断append列表可能会经历重新分配、整体复制产生更大的临时内存开销。元组一旦创建大小就锁死没有任何扩容负担。从元素存储方式看两者其实都是存放指针数组。元组每个槽位指向一个Python对象列表同样如此。但列表多出的动态管理机制让它在小规模数据上就明显更“胖”。如果数据规模再大一点比如十万个整数列表和元组的内存差距会更明显而且列表在扩容过程中的峰值内存可能达到实际需要的好几倍。2.2 创建与遍历速度对比速度差异主要体现在创建和遍历上。元组的字节码指令更少因为不需要动态调整长度。我习惯用timeit做粗略对比import timeit print(timeit.timeit([1, 2, 3, 4, 5], number1_000_000)) print(timeit.timeit((1, 2, 3, 4, 5), number1_000_000))在我自己的机器上跑下来创建相同内容的列表和元组元组普遍快约20%~30%左右不同Python版本会有波动。遍历访问的速度差距没有创建时那么夸张但仍能感觉到元组略快。这个性能差距的根源是Python内部对元组有额外的优化机制。从3.12版本开始如果元组里的所有元素都是可复用的常量对象比如小整数、字符串字面量并且元组长度不超过一定阈值解释器甚至可以直接把整个元组做成常量缓存起来。也就是说某些短元组在编译阶段就可能直接复用同一个对象根本不需要在运行时重新创建。列表永远享受不到这个待遇因为列表可变一旦复用某个列表改一处就会影响所有引用它的地方。2.3 为什么元组会被优化不可变带来的自由度很多人只知道“元组快”但不清楚为什么。核心原因是不可变给解释器腾出了优化空间。列表是可变对象解释器在创建列表时不能假设它永远不变任何地方拿到列表都可能修改它的长度或内容。所以每当列表被append或insert解释器都必须重新检查容量、可能触发内存重新分配。这一套操作包含条件分支、内存申请甚至有可能触发垃圾回收的连锁反应。元组是不可变的解释器可以大胆假设“它永远不会变”所以不需要预留多余容量可以安全缓存短元组哈希值可以被缓存后续重复计算时直接复用。哈希值缓存这一点很值得展开说。元组作为字典键时每次hash(tuple)都会调用tuplehash但Python内部会把这个计算出的哈希值保存在元组对象的缓存字段里后续再用时直接返回不会重算。列表没这个待遇根本原因还是“内容可能变缓存了哈希也没用”。3. 应用场景别再写“反正都能用”的代码了3.1 优先用元组的四类场景第一类函数返回多个值时。比如return x, y或者return 200, ok。这里返回的就是元组调用方用a, b func()解包。这既是一种习惯也是一种约定函数只负责产出一次结果外部拿到之后如果不解包直接存着也不希望它被改来改去。第二类字典的键和集合元素。只要需要“不可变 可哈希”元组就是默认答案。典型的例子是坐标点、矩形的对角线坐标、二维网格里的格子位置visited set() visited.add((row, col))你当然也可以用字符串row:col硬拼但那样解析起来麻烦还容易出事。元组直接表达“多个相关值的组合”比字符串拼接干净得多。第三类记录固定字段的数据。比如一个人的身份证号、姓名、出生日期一条数据库查询返回的一行数据一组配置项(admin, 127.0.0.1, 8080)。这些数据的结构是固定的字段数量不会变用元组更合适还能防止代码里无意中增删字段导致结构损坏。第四类性能敏感的循环内部。如果你在大量循环里反复创建短小的容器比如几百万次迭代每次都生成[a, b, c]或(a, b, c)用元组能省下一点创建时间和内存。这个优势单个实例非常小但放大到百万、千万级循环里是能感受到的。3.2 优先用列表的场景需要动态增删元素几乎就是列表的天下。典型的场景包括收集用户输入一行一行读文件再往列表里append维护一个待办队列或操作栈保存中间计算结果后面还要过滤、去重、排序需要调用sort就地排序的场景。这些操作天然要求“容器长度可变”元组根本做不到。列表还有一种场景容易被忽略当成栈或队列用。虽然栈更适合用列表模拟append、pop是在尾部操作时间复杂度是O(1)队列一般会建议用collections.deque但如果你只是偶尔做简单的先进先出直接对列表做pop(0)也不是不行只是数据量一大就明显变慢。此时正确的做法是换数据结构而不是换成元组。3.3 一个简单好记的选型逻辑我自己在写代码时判断方式非常简单如果这个容器的长度是固定的、语义上不会变优先元组。如果这个容器在生命周期里会长大或缩小只能用列表。如果要用它做字典键或放入集合只能是元组或者frozenset这类不可变类型。如果只是临时打包几个值传参或返回元组最合适。如果需要对内容做排序、反转、去重等操作且希望改变容器本身列表。记住一个原则元组是数据列表是工具。数据指的是它承载的信息有固定结构工具指的是你要对它进行一系列加工操作。4. 边界与坑元组不是“完全不可变”4.1 元组里的可变对象前面提到过元组的不可变是“浅不可变”。如果元组里包含列表、字典、集合这些可变对象这些对象的内容依然可以被修改tup ([1, 2], {a: 1}) tup[0].append(3) tup[1][b] 2 print(tup) # ([1, 2, 3], {a: 1, b: 2})这带来几个很隐蔽的问题第一如果你把包含列表的元组作为字典键会直接报错因为包含列表的元组是不可哈希的# d {([1],): x} # TypeError: unhashable type: list第二如果元组作为函数参数传给别人元组本身确实改不了但元组里的列表可能被函数修改等于“不变量被破坏”。所以如果你真的想构造一个“完全不可变”的记录元组还不够得借助types.MappingProxyType或者自己重写一个不可变容器类或者干脆存成frozenset但frozenset结构又不一样。第三元组里的可变对象会破坏“缓存哈希值”的优化逻辑。一个包含列表的元组本身不可哈希自然无法作为字典键被缓存。如果元组里的可变对象被修改前面说到的静态缓存例如3.12的短元组常量复用也不会受影响因为那个缓存只针对纯常量元素。4.2 元组作为字典键的完整条件并不是所有元组都能当字典键。条件有三个元组本身不可变这个满足元组的所有元素都不可变且可哈希不存在嵌套的列表、字典、集合。举例来说a (1, 2) # 可做键 b (1, [2]) # 不可做键因为包含列表 c ((1, 2), (3, 4)) # 可做键嵌套的都是元组 d (1, (2, [3])) # 不可做键 e ([],) # 不可做键在实际业务中最常见的用途是地理坐标、状态组合、组合索引。例如一个表中需要根据(user_id, order_id)去快速查找结果用一个元组字典键就不需要写一长串条件判断非常直接。4.3 解包与星号表达式元组的“隐藏用法”元组的解包能力是它很受欢迎的原因之一。a, b (1, 2)本质是元组解包但其实任何可迭代对象都能解包列表也一样a, b [1, 2] first, *rest (1, 2, 3, 4)但元组的特殊之处在于函数返回多个值时语法上直接写成return a, b不需要加括号这让代码看起来非常干净。接收的时候也是一样x, y get_point()。这种语法在Python 3里还被扩展到了更多场景循环里解包、函数定义里收集位置参数*args、函数调用里展开可迭代对象func(*args)这些都有元组的身影。另外很多人没用过但很推荐的collections.namedtuple。它能给元组槽位起上名字像访问属性一样访问元素from collections import namedtuple Point namedtuple(Point, [x, y]) p Point(3, 4) print(p.x, p.y) # 3 4 print(p[0]) # 3仍然兼容元组的下标访问再进一步Python 3.6的typing.NamedTuple还支持类型注解from typing import NamedTuple class Point(NamedTuple): x: int y: int p Point(3, 4)这类命名元组特别适合表示“字段名稳定的记录”既保留了元组的轻量、不可变、可哈希又避免了tuple[0]这种晦涩的访问方式。我在实际项目中经常用NamedTuple替代普通的类因为少写很多样板代码性能还更好。4.4 常见的几个误区与避坑误区一认为元组不能进行和*运算。其实元组可以拼接和重复只不过每次都会生成一个新元组a (1, 2) b a (3,) # (1, 2, 3) c a * 3 # (1, 2, 1, 2, 1, 2)误区二认为a * 3是重复浅拷贝。如果元组里包含列表(lst,) * 3其实是同一个列表出现三次lst [1] tup (lst,) * 3 tup[0].append(2) print(tup) # ([1, 2], [1, 2], [1, 2])这个陷阱在列表上同样存在只是一旦套在元组里很容易让人放松警惕。解决方法是使用元组内的不可变元素或者在拷贝时明确用tuple([lst.copy() for _ in range(3)])。误区三觉得元组比列表“更好”就到处用。如果你需要动态增删元组就是没法用如果你需要调用sort但不想产生新对象元组也做不到。性能优势必须建立在“满足功能需求”的前提下。误区四忽略元组的空和单元素特殊情况。()是空元组但(1)其实是数字1不是元组。单元素元组必须写成(1,)多了这个逗号。这是新手很容易踩的语法坑而且错误往往很隐蔽因为(1)可以正常参与运算只是类型完全不对。5. 实操速查面试、代码评审与日常选型建议5.1 面试中该怎么答如果面试官问“列表和元组有什么区别”我个人建议从浅到深给出四层回答既有条理又能展示底层理解。第一层可变性。列表可变元组不可变这是根本区别。第二层性能。元组因为不可变内存结构更紧凑创建速度更快可以作为常量缓存作为字典键时还能缓存哈希值。第三层用途。元组适合固定结构的数据记录、函数多返回值、字典键、集合元素列表适合动态数据集、需要频繁增删改查的序列。第四层边界条件。元组的不可变是浅层的内部包含列表时依然可变单元素元组要加逗号命名元组可以结合两者优点。如果能随口说一句“如果有了命名元组我们甚至可以在保持元组特性的同时获得属性访问语义”那就更好了。因为面试官想听的不是背定义而是“你是否真的理解这种东西存在的理由”。5.2 代码评审时我会重点看什么代码里出现容器选型不当是我在评审时最常提的问题之一。以下是我自己的检查清单看这个容器会不会被append或insert。如果是基本可以确定该用列表。看这个容器会不会长期保持不变。如果在整个函数里只是读取而且创建时数据量不大用元组更合适。看这个容器是否要作为字典键或放进集合。是的话用元组如果数据结构允许。看数据有没有固定字段语义。比如一个点(x, y)、一个RGB颜色(r, g, b)、一条日志的元组(time, level, message)用元组或NamedTuple更贴切。看返回值为多个值时有没有被调用方错误地修改。如果有人把函数返回的元组转成列表再修改建议先停下来思考为什么需要修改是不是设计上不合理。5.3 一个可以抄作业的判断流程图非图表版用文字描述我日常写代码时的判断过程if 容器长度会变化 用列表 elif 容器要做字典键或进集合 用元组且元素全部可哈希 elif 容器只是临时打包多个值 用元组 elif 容器表示固定结构的记录 用元组或NamedTuple elif 需要就地排序、反转、append 用列表 else 默认元组更轻量这套逻辑很粗糙但足够覆盖绝大多数场景。真正细节的取舍还是要结合业务场景。5.4 结尾我个人的实操体会先说一个我自己曾经踩过的坑。做一个数据处理脚本时我习惯把每条日志都用元组存起来图的是结构固定、省内存结果后来业务需求变了需要随时给日志追加字段。我花了不少功夫把所有元组改成列表又因为元组作为键存在字典里连带字典重构。那次之后我就明白了一个道理选型要先想清楚数据的生命周期如果需求还在快速变动期宁可先用列表等稳定了再优化也不迟。再分享一个小技巧如果你不确定该用列表还是元组可以先写成元组。因为从元组改成列表很容易从列表改成元组则可能在依赖方引发大量改动。反过来如果你确定要动态操作别犹豫直接用列表。最后不用太执着于那一点性能差异在绝大多数业务代码里列表和元组的性能差距远小于一个不合理的算法或一次多余的网络请求。真正重要的是用对容器表达清楚你的数据意图让下一个读你代码的人不用猜。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑