首页 / 资讯中心 / 文章详情

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

Python列表与元组:可变性、哈希、性能与选型全解析 ★ FEATURED ARTICLE
每次面试聊到 Python 基础列表和元组这对 CP 几乎是必考题。先抛个结论如果你只记得“列表可变、元组不可变”这八个字那你在真实项目里大概率还是凭感觉选遇到性能问题、序列化问题和哈希报错的时候才发现根本原因没弄明白。这篇文章我会从 CPython 的底层存储、性能实测、经典坑、面试高频考点几个角度展开把选型逻辑一次讲透。无论你是刚学完基础想深入理解还是准备面试想答出点别人答不出的细节都值得读完。先说清楚一件事网上很多文章把元组叫“不可变列表”这个叫法很容易误导人。元组和列表的差异不只是“能不能改”这么表面它们在内存布局、哈希能力、编译优化上都完全不同。你只有把这几层都理解了才能在写代码的时候快速判断该用谁。1. 列表与元组的核心差异可变性、哈希、底层结构一次讲清1.1 可变性所谓“不可变”锁住的到底是什么最直观的差异写代码就能看到。列表可以原地增删改元组一改就报错lst [1, 2, 3] lst[0] 100 lst.append(4) print(lst) # [100, 2, 3, 4] t (1, 2, 3) t[0] 100 # TypeError: tuple object does not support item assignment t.append(4) # AttributeError: tuple object has no attribute append但这里有个很多人理解歪了的地方元组的不可变指的是“元组对象一旦创建它的长度和每个槽位里保存的引用都不能变”并不是说它里面的元素对象本身永远不能被修改。我后面会专门用一小节来讲这个坑因为那是面试和实际开发里最容易翻车的地方。用一个生活类比帮你建立直觉列表像你钱包里的购物清单随时可以用笔划掉、补充、改顺序元组更像一本印好的合同想改条款只能重新签一份新的。合同不能涂改但合同里约定的那个仓库内部怎么堆货合同管不着。这个类比你理解透了后面很多问题就顺了。另外提一个很容易被忽略的操作元组拼接。t (1, 2); t (3, 4)看起来像“修改”了元组实际是 Python 帮你新建了一个元组对象再让变量指向它。原元组早就被回收了。你用id()去看就能发现地址变了这一点和列表的完全不同列表的是在原地做 extend。1.2 哈希能力为什么列表不能当字典键这是列表和元组差异里最实用的一条元组可以作为字典的 key、可以放进集合列表不行。d {(1, 2): ok} s {(1, 2), (3, 4)} print(hash((1, 2))) try: hash([1, 2]) except TypeError as e: print(e) # unhashable type: list原因其实很简单字典和集合依赖哈希值来定位数据而哈希值要求对象在生命周期内保持稳定。元组不可变所以只要元组里的元素都可哈希这个元组整体就可以哈希列表可变你把它放进字典之后还能往里面加东西那它该在哪个桶里待着哈希值变了整个字典就乱套了。所以 Python 直接不让它哈希。这个能力在工程里非常有价值。比如你要缓存某个函数的结果参数是坐标点(x, y, z)那就可以直接用元组做 keycache {} def compute(x, y): key (x, y) if key not in cache: cache[key] do_something(x, y) return cache[key]如果你是用[x, y]当 key这条代码根本写不出来。这种组合键场景元组天然就是正确答案。1.3 底层存储CPython 源码告诉你的真相如果你平时只看 Python 表面用法会觉得列表和元组就是差在方括号和圆括号上。但打开 CPython 源码看一眼你会发现两者的存储模型完全不是一回事。列表对象PyListObject内部除了保存元素的指针数组ob_item还有一个allocated字段表示当前容量。容量通常大于列表实际长度所以当你append的时候只要容量还没满就不用重新分配内存只有满了才扩容。这是列表能实现均摊 O(1) 追加操作的底层保证。代价就是列表会预留多余空间内存上有点浪费。元组对象PyTupleObject呢只有一个ob_item数组没有allocated字段。因为元组长度固定创建的时候一次性分配刚好够用的空间不用预留也没有扩容逻辑。用sys.getsizeof实测一下就非常直观import sys print(sys.getsizeof([1, 2, 3])) # 在我的 Python 3.11 环境里是 88 print(sys.getsizeof((1, 2, 3))) # 是 64不同 Python 版本数字会有浮动但“同样元素的元组比列表小”这个结论是稳定的。空对象也能看出差别CPython 里空元组是一个单例对象验证一下a () b () print(a is b) # TrueCPython 下空元组是同一对象 c [] d [] print(c is d) # False空列表每次都是新对象我提醒一下() is ()这个结果是 CPython 的实现细节不应该当成可依赖的语法规则写进业务代码。但理解它对你理解元组的内存优化有帮助元组作为不可变对象可以被安全复用列表可变永远不能用同一个对象撑两个变量。2. 性能实测与内存对比元组是不是真的更快更省2.1 创建耗时对比用 timeit 实测而不是靠感觉网上总说元组比列表快但大家很少说清楚“快在哪个环节”。我建议你直接跑一下timeit自己验证import timeit print(timeit.timeit((1, 2, 3, 4, 5))) print(timeit.timeit([1, 2, 3, 4, 5]))我本机跑的结果元组字面量创建的时间大约只有列表的一半到三分之一。造成这个差距的原因有两个层面。第一是字节码层面。Python 编译器对元组字面量做了常量折叠优化(1, 2, 3, 4, 5)在编译阶段直接被合并成一个常量运行时一条LOAD_CONST就加载完了。列表不一样因为列表是可变对象编译器不会把它缓存成常量运行时必须逐条加载元素再执行BUILD_LIST。这一点后面我会用dis模块验证。第二是内存分配层面。元组创建时一次分配固定大小分配器路径更简单列表创建通常预留容量还可能有扩容逻辑。就算数据不是字面量而是从变量构建元组分配的开销也普遍低于列表。不过说句实在话这种性能差异在绝大多数业务系统里是可以忽略的。除非你是在循环里高频创建成千上万个小对象否则别把“快”当成选元组的首要理由。代码语义和可维护性比这点微秒级差异重要得多。2.2 内存占用对比列表的预分配是个隐藏成本列表真正费内存的地方很多时候不是列表对象本身而是它“提前多占”的那部分容量。你看下面这个例子import sys lst [] for i in range(1000): lst.append(i) print(sys.getsizeof(lst)) # 比如 9016 print(sys.getsizeof(tuple(lst))) # 比如 8056列表在这种情况下实际分配的容量往往比 1000 个元素需要的空间多出一些这是为了后续 append 不频繁触发扩容。你很难精确控制它只能等列表长度稳定后手动调用list.copy不真正想省内存可以考虑转成元组或使用array模块。但代价是失去了可修改能力。如果你的应用内存很紧张并且有一批数据一旦创建就不会再变把列表转成元组确实能省一点内存。但更极端的做法是用array.array或者numpy它们直接存原始数值而不是 Python 对象引用内存优势更明显。元组只是“比列表稍微省一点”它依然是 Python 对象容器别指望它能替代专业数值库。2.3 操作复杂度差异原地修改、拼接与重新创建列表和元组在操作复杂度上差别很明显尤其当你需要频繁增删元素的时候。操作列表元组尾部 append / pop均摊 O(1)不支持中间 insert / removeO(n)不支持按索引取值O(1)O(1)成员查找x in tO(n)O(n)拼接a b创建新列表创建新元组原地扩展原地 extend创建新元组并重新绑定注意最后一行很多人以为元组不能其实可以但它不是原地操作。列表的相当于 extend在原列表上追加元组的是“我创建了一个新元组再把名字绑定过去”。如果你在代码里频繁对元组做拼接操作那其实是把元组当成一个“每次重建的临时容器”在用这时候你应该反思一下是不是该改用列表了。从性能角度说元组的优势集中在“一次创建、多次读取”的场景。如果数据集合在生命周期里会不断变化硬用元组并靠拼接来模拟修改性能和代码可读性都会很难看。3. 应用场景判断什么时候该用列表什么时候该用元组3.1 多返回值、解包和可变参数元组的天然主场Python 里return a, b返回的其实就是元组这是最基础也最常见的元组应用。函数内部约定这几个值是一个整体外部接收的时候直接解包def get_user_info(): return alice, 30, engineer name, age, job get_user_info()函数参数*args也是一个元组尽管你可以把它转成列表去修改但绝大多数情况下你只是遍历或者传递给下一个函数没有必要让它可变。解包操作还衍生出一个非常经典的交换变量写法a, b b, a这行代码底层做的就是“用右侧元素创建临时元组、再拆包赋值”。这些场景的共同点是数据组织方式是固定的数量是确定的用途是只读传递。元组在这里是最自然的选择换成列表反而会多出可修改的能力容易造成误操作。3.2 同构集合与固定字段组合语义决定选择我概括一个简单好用的判断标准你在项目里直接套用就行列表适合“一个可能变长的、通常元素类型相同的数据集合”比如用户列表、日志列表、待处理任务队列。你需要遍历、筛选、排序、增删这些操作都是列表的主场。元组适合“一个长度固定、每个位置有固定语义的数据组合”比如坐标(x, y)、RGB 颜色(r, g, b)、时间段(start, end)、数据库查询出来的一行记录。用代码说人话# 列表数量不确定同类数据 usernames [alice, bob, carol] usernames.append(dave) # 元组字段固定语义明确 point (34.05, 118.25) # 经度、纬度 color (255, 0, 0) # 红、绿、蓝 period (2025-01-01, 2025-12-31)有人会抬杠说“元组里也可以全是整数呀比如(1, 2, 3)它不也是同构集合吗”没错判断标准不是“是否同构”而是“长度是否固定、是否需要修改、是否允许被意外改坏”。如果(1, 2, 3)是一个三维向量它的长度就是固定的 3那用元组就是对的如果它是一串待累加的整数数量会变那明显应该用列表。3.3 可读性与序列化元组的隐藏成本不能忽略元组还有一个明显的可读性优势config (127.0.0.1, 8080)一眼就能看出这个配置是两部分组成的固定结构。如果你用[127.0.0.1, 8080]阅读代码的人会疑惑这是个动态列表吗后面会不会有人往里 append使用元组等于在代码里声明了一句话这块数据的结构是固定的别乱改。但要注意一个隐藏成本序列化。元组和列表在 JSON 序列化里长得一样都会被转成 JSON 数组从 JSON 读回来的时候你得到的一定是列表类型信息丢了。import json data json.dumps((1, 2, 3)) print(data) # [1, 2, 3] print(type(json.loads(data))) # class list这在跨服务调用、缓存存储、配置文件解析时很容易出问题你明文写了元组读回来发现变成列表后续hash()或者当字典 key 用就直接报错。我建议凡是要经过 JSON 存储或网络传输的数据在写入前明确它的类型读出后主动做一次类型判断或转换不要依赖“Python 内部类型能原样保存”的错觉。3.4 换个思路namedtuple 才是固定结构的正解如果元组的每个位置有固定语义但光靠数字下标读数据太难维护namedtuple是更好的选择。from collections import namedtuple Point namedtuple(Point, [x, y]) p Point(1, 2) print(p.x, p.y) # 通过字段名访问namedtuple保持元组的所有特点不可变、可哈希、省内存只是额外给每个字段起了名字。它特别适合表示一条数据记录比如一个测试用例的输入输出、一个小范围内的配置项或者报表里的一行数据。如果你需要的字段更多结构更复杂还要增加默认值、类型标注和方法那可以考虑typing.NamedTuple或者dataclasses.dataclass(frozenTrue)from typing import NamedTuple class Point(NamedTuple): x: int y: int这个方案在代码可读性和类型安全上都比裸元组好。我的建议是固定结构的场景优先考虑namedtuple或NamedTuple裸元组更适合临时传递、解包和函数多返回值列表则留给真正需要动态增删的场景。4. 元组使用中的经典坑嵌套可变对象、单元素陷阱、序列化丢失类型4.1 元组嵌套列表不可变引用 vs 可变对象很多人搞反了这是元组知识点里最经典的坑我把它放在第一位。看这段代码t ([1, 2], fixed) t[0].append(100) # 合法 print(t) # ([1, 2, 100], fixed) t[0][0] 99 # 合法 print(t) # ([99, 2, 100], fixed) t[0] [3, 4] # TypeError: tuple object does not support item assignment你发现了元组本身不能被重新赋值但它里面套着的列表可以随便改。这其实印证了我在 1.1 说的那句话——元组存的是引用不可变的是“这些引用组成的数组”不是引用指向的对象本身。这个现象让很多人惊呼“元组也没那么不可变”我认为准确的说法是元组的“壳”不可变但壳里面的可变对象依旧是可变的。想构造一个真正深度不可变的组合你得保证所有嵌套元素都是不可变类型整数、字符串、不可变元组、frozenset等。只要里面混进一个列表或字典这个元组就会变得“半可变”而且它也会因此失去可哈希能力try: hash((1, [2, 3])) except TypeError as e: print(e) # unhashable type: list所以在设计数据模型时如果你声明一个元组并要求它深度安全要么用全不可变元素要么干脆用frozenTrue的 dataclass借助运行时的约束帮你兜底。4.2 单元素元组与逗号问题括号真的会骗人这是入门级老坑但工作几年的人也偶尔翻车。关键规则创建单元素元组时一定要加那个逗号。a (1) print(type(a)) # class int括号被当成优先级符号了 b (1,) print(type(b)) # class tuple我在排查别人的代码时见过类似的错误从函数 return 一个(error_code)调用方拿到的其实是个整数然后去.append或者解包直接报错。函数返回单元素元组时一定要写return (error_code,)或者return error_code,。还有一个容易混淆的写法values [(1), (2), (3)]表面上很像“三个单元素元组的列表”实际上它是包含三个整数的列表。如果你要的是元组列表必须写成[(1,), (2,), (3,)]。这种问题在构造 SQL 查询参数、批量数据的时候很容易出现建议养成写测试用例量时的习惯遇到单元素场景多看一眼逗号。4.3 序列化与相等性元组会被悄悄变成列表除了 JSON很多存储和传输协议都会把元组当成数组处理。比如你写 Redis 时存一个元组客户端拿回来的往往是列表。这个不是 Python 语言层面的问题而是协议本身没有“元组”这个概念。另一个相似坑是相等性判断。内容相同的元组和列表永远不相等因为会先比较类型print((1, 2) [1, 2]) # False这很直观但我在代码评审里真的见过有人拿配置文件里的列表和返回值里的元组直接做相等判断结果发现永远对不上。你要么统一类型要么在比较前显式转换。还有前面提过空元组在 CPython 里是单例对象有些人会想用if t is ()来判断空元组。这个能用但千万别在跨实现或者明确依赖对象身份的地方这么写。标准写法还是if not t:或者if len(t) 0:。5. 面试高频考点与自测脚本从字节码到实战验证5.1 面试题速查表5 个高频问题一次看全我把面试里最常见的列表/元组问题整理成一张速查表方便你快速过一遍问题标准答案要点为什么元组能当字典 key列表不能元组不可变、可哈希列表可变、不可哈希元组真的完全不可变吗元组本身不可变但嵌套的可变对象仍可修改(1)和(1,)有什么区别前者是整数后者是单元素元组t (4,)是原地修改吗不是它创建新元组再重新绑定变量元组和列表谁更快更省内存通常元组更快更省内存原因包括常量折叠、无容量预分配面试官如果追问“为什么元组可哈希的底层逻辑是什么”你要能答出“哈希值由各元素的哈希组合而来一旦创建不可变所以哈希稳定”。如果能再补一句“包含列表等不可哈希元素的元组也无法哈希”那就更完整了。5.2 用字节码验证为什么元组字面量更快光说“元组更快”不够有说服力我建议你用dis模块看看字节码这是很多教程不会展开的部分但看完你就能彻底理解常量折叠。import dis dis.dis((1, 2, 3)) print(---) dis.dis([1, 2, 3])在主流 CPython 版本里输出大致是1 0 LOAD_CONST 0 ((1, 2, 3)) 2 RETURN_VALUE --- 1 0 LOAD_CONST 0 (1) 2 LOAD_CONST 1 (2) 4 LOAD_CONST 2 (3) 6 BUILD_LIST 3 8 RETURN_VALUE看到了吗元组字面量被编译器优化成一个常量运行时只需要加载一次列表字面量必须把每个元素逐个加载再调用BUILD_LIST组装。这就是元组字面量在创建性能上占优的根本原因之一。这也体现了一个通用原理不可变对象可以安全地做编译期优化可变对象不行。5.3 一套可以直接跑的验证脚本如果你想自己动手验证今天讲的内容下面这个脚本可以直接跑import sys import timeit import dis # 内存对比 print(list size:, sys.getsizeof([1, 2, 3])) print(tuple size:, sys.getsizeof((1, 2, 3))) # 空对象单例 print(() is ():, () is ()) print([] is []:, [] is []) # 创建耗时 print(tuple:, timeit.timeit((1, 2, 3, 4, 5))) print(list:, timeit.timeit([1, 2, 3, 4, 5])) # 字节码 dis.dis((1, 2, 3)) print(---) dis.dis([1, 2, 3]) # 嵌套可变 t ([1, 2], 3) t[0].append(100) print(t) # ([1, 2, 100], 3)我建议你修改几个变量再跑比如试试不同长度、试试包含变量的构造方式你会发现结论不会变元组在创建和只读场景下更轻列表的优势在动态修改。看到实测数据后你的直觉会比记任何结论都可靠。6. 多年实践下来的选型经验最后聊点我自己的使用习惯。写 Python 这些年我判断一个数据该用列表还是元组只问三个问题这个数据会变长或变短吗这个数组成员的位置有语义吗这个数据需要被哈希或者当 key 用吗第一个问题答案是“会变”直接选列表。第二个问题答案是“有语义”优先考虑元组或 namedtuple。第三个问题答案是“是”那必须用元组或 frozenset。三个问题问完基本不会选错。我踩过的一个真实教训是在某个数据处理流程里为了“不可变更安全”这个信念把一份动态任务队列也用元组来维护每次往里加任务就拼一个新元组。结果不仅代码难看内存分配也变得频繁最后排查性能问题才发现罪魁祸首就是这种“用元组硬模拟动态集合”的写法。自那以后我给自己定了一条规矩不要为了所谓的安全性让元组承担它不该承担的动态增删职责。安全性的正确实现方式是控制可变对象的暴露边界而不是把所有容器都变成元组。反过来处理配置项、权限点、坐标组合、函数多返回值这类天生固定结构的数据我几乎无条件用元组。遇到别人问我为什么不用列表我的回答永远不是“因为快”而是“因为这堆数据从设计上就不该被改”。把意图写进代码里比省那几十纳秒重要得多。如果你想继续深挖建议下一步学习dataclasses的frozenTrue与NamedTuple的差异、frozenset的使用场景以及如何设计不可变数据模型。理解可变性与数据建模的关系才是列表和元组这个话题真正的终点。
阅读完成 · 觉得有帮助?
咨询建站