
1. 先从“看起来一样”说起列表和元组是两种操作心态我见过不少刚接触 Python 的朋友被“列表和元组长得那么像为什么要搞出两个东西”这个问题卡住。明明都能写[1, 2, 3]和(1, 2, 3)都能按下标取数据都能切片、循环、判断长度恍惚间你会觉得它们就是同一种容器换了副括号而已。但实际用起来一旦你试图my_list[0] 99发现可以然后对my_tuple[0] 99却报错时就会意识到事情没那么简单。这个报错并不是 Python 故意为难你而是它想通过语法层面提醒你列表和元组在编程心态上就不是一回事。我自己的经历是几年前写一个数据处理脚本想把一组坐标(x, y)存进字典做缓存图省事用了列表[x, y]结果一运行就抛TypeError: unhashable type: list。当时的第一反应是“为什么数字能当键列表不能”后来翻开文档才明白字典的键必须可哈希而可哈希本质上要求这个值一旦创建就不能变。列表能随便增删元素自然没资格当键元组不能修改反倒成了合格的“身份证号”。所以想彻底弄懂列表和元组不能只看“能装什么”要看“它们允许你怎么操作”。这一篇我就结合自己的实际体验把两者从原理到场景从头捋一遍该给代码的给代码该给结论的给结论希望能帮你一次理清。为了方便后续讨论先约定两个通俗叫法列表list是“活页本”元组tuple是“塑封卡片”。活页本可以随时撕页、加页、改内容塑封卡片印好之后就不能再动了除非整张作废重印。这个比喻虽然粗糙但和它们在 Python 里的行为能力非常贴合。同时它们又都属于“序列”这个大家族都支持索引、切片、相加、重复、in判断、len()。这意味着如果你只是遍历数据、读取数据你经常会感觉不到差异。差异只会发生在“你试图修改容器本身”的那一瞬间。很多教材喜欢用“可变对象”和“不可变对象”来区分这个概念没错但它容易让人产生两个误解第一觉得不可变就是“永远不能变”可实际上元组里如果装了列表列表里的元素照样能改第二觉得可变性只影响“能不能改”但事实是它还会影响哈希、性能、并发安全、甚至代码可读性。接下来我会把这些一层层拆开。2. 可变性这份合同列表能改元组签完就不能改2.1 列表的“活页”操作增删改都能做列表天生为“不断变化的集合”准备。常用的修改操作大致有这些users [Alice, Bob] users.append(Carol) # 追加变为 [Alice, Bob, Carol] users.insert(1, David) # 插入变为 [Alice, David, Bob, Carol] users[0] Eve # 替换下标 0 的元素 users.remove(Bob) # 按值删除 popped users.pop() # 弹出最后一个元素 users.extend([Frank, Grace]) # 批量追加 users.sort() # 原地排序 users.reverse() # 原地反转这些操作全都是“原地修改”列表对象本身不需要重新创建一个新列表。所以当你把一个列表传给函数函数里append一下外面的列表也会变这就是所谓“引用传递”的实际表现。写代码时如果不留神很容易造成隐式 bugdef add_item(items, new_item): items.append(new_item) return items my_list [1, 2] result add_item(my_list, 3) print(my_list) # [1, 2, 3]原列表被改了这种行为在需要“共享同一份数据集”的场合很有用比如多个函数都要往同一个队列里塞任务但在“函数不应该改变外部状态”的函数式风格里就得格外小心。Python 里惯用的做法是“要改就别返回要返回就别改”如果你希望函数不污染外部列表就得用切片复制一份def add_item_safe(items, new_item): copy items[:] copy.append(new_item) return copy2.2 元组的“塑封”特性不能赋值但能整体替换元组一旦创建就不能对已有元素重新赋值。这不是写代码时的风格问题而是语法层面的强制约束t (1, 2, 3) t[0] 100 # TypeError: tuple object does not support item assignment而且元组也没有append、extend、remove、sort这类原地修改方法。如果你想得到一个“变了的新元组”只能基于旧元组创建新对象例如拼接或切片t (1, 2, 3) new_t t (4, 5) # (1, 2, 3, 4, 5)有人会问那t (1, 2)之后重新赋值t (3, 4)算不算“元组可变”严格说这改变的是变量t这个标签指向的对象而不是元组本身。原来的(1, 2)仍然存在只是不再被引用。元组的不可变性指的是对象内部状态不可变而不是变量不能指向新对象。这和“给整数变量重新赋值”是一个道理a 1; a 2并不会让1变成2只是让a指向了另一个对象。2.3 藏在不可变背后的“例外”元组里装列表元组自己不能改不代表它管得住里面的可变对象。下面这种代码经常让人晕t (1, 2, [3, 4]) t[2].append(5) print(t) # (1, 2, [3, 4, 5])为什么元组没报错因为t[2]是一个列表对象元组只记住了“这个位置有一个列表对象”的引用。你调用append是借助这个引用去修改列表内部元组自身的内容依然是“指向那个列表指针”。所以可不可变要看对象内部允不允许修改而不是看外层容器长什么样。这个例外在实战里最常见的地方就是你拿元组当字典键时里面不小心装了列表key (1, [2]) d {key: value} # TypeError: unhashable type: list原因在于 Python 计算元组哈希值的时候需要递归计算每个元素的哈希值一旦遇到列表这种不可哈希对象整个元组就跟着失去哈希能力。这个限制并不是多此一举如果允许键里藏可变对象那键的内容一变哈希值就变字典就再也找不到原来的位置了。2.4 可变性带来的连锁差别哈希、拷贝与比较列表和元组最直观的连锁差异体现在三件事上哈希列表不可哈希元组若内部元素全是不可哈希的对象则可哈希。因此元组能放进集合、能当字典键列表不行。拷贝copy.copy()都做浅拷贝但拷贝列表可以用切片[:]、copy()元组一般直接复制引用即可因为内容不可变浅拷贝与深拷贝结果没有本质区别。比较与相等列表和元组比只有当类型相同且元素逐一相等时才为 True列表[1, 2]与元组(1, 2)比较结果是 False。这一点经常在测试断言里坑人。下面这段代码可以帮你直观感受到两者的“不同身份”a [1, 2, 3] b (1, 2, 3) print(a b) # False类型不同 print(a [1, 2, 3]) # True print(b (1, 2, 3)) # True print(hash((1, 2))) # 能输出一个整数 # print(hash([1, 2])) # TypeError: unhashable type: list从这些差异里你能看出可变性是 Python 数据类型的“底层操作系统”它决定了一个对象能不能参与哈希运算、能不能被安全共享、能不能原地扩展。只要记住了这条主线列表和元组的大部分区别都能推导出来。3. 拆解内存与性能元组为什么“瘦身”又能打如果你在写一个需要频繁创建小批量数据、并且还要被很多人读取的后端服务可能迟早会关心一个问题元组是不是真的比列表快快多少为什么快我最初也只是听说“元组性能更好”但直到我看了 CPython 的底层数据结构才算真正理解“快在哪里”。3.1 底层结构一个固定包厢一个弹性大厅CPython 里元组对象的核心是一个数组长度在创建时就固定了。你可以把它理解成预订了一排固定数量的座位每个座位存一个指向 Python 对象的指针。因为数量不变不需要预留多余空间也不需要考虑扩容时搬家。列表对象则不同。它内部除了指向元素的指针数组还有一个“已分配容量”。这个容量通常大于当前元素个数为的是后面append时不用每次都在内存里重新找地方放数组。当元素个数超过容量时Python 会执行一次扩容操作申请一块更大的内存把原数据拷贝过去释放旧内存。通常扩容比例大约是原来的 1.125 倍保证均摊下来每次append的时间复杂度仍是 O(1)。这样对比元组在内存上的优势就出来了元组没有“额外预留空间”用多少内存就占多少创建列表时可能已经预留了很多空槽位元组创建完成后结构永远不会变也不会有重新分配的内存搬运。列表同样数量元素时实际占用内存往往比元组高出一个单位量级。你可以用sys.getsizeof()简单测一下import sys lst [1, 2, 3, 4, 5] tup (1, 2, 3, 4, 5) print(sys.getsizeof(lst)) # 在我的环境里是 104 字节 print(sys.getsizeof(tup)) # 在我的环境里是 80 字节不同版本和环境数值会有差异但趋势很稳定元组更轻。这个差别在单个元素上不起眼但如果你的程序里有几百万个这样的容器多出来的内存可能就是几百 MB 与几十 MB 的差距。3.2 创建、遍历与读取到底谁更快我拉起一个简单循环分别创建一千万个列表和一千万个元组实测下来元组创建明显更快。原因很直白列表创建时要先构造一个“先导版”结构预留容量而元组只需要按元素个数分配一次内存再把元素引用摆进去。读取元素方面的差距相对小因为下标访问本质都是“根据偏移量取指针”。但是遍历已经创建好的数据时元组仍然有微弱优势因为它不需要额外的容量判断也能更友好地利用 CPU 缓存。这些差异在普通业务逻辑里基本感受不到只有在数据量大、循环密集的数值计算或日志处理场景里才值得一提。有一点容易被人忽略元组不可变所以 Python 对某些元组可以做一些额外优化。比如只有一个元素的元组可以复用空元组也复用同一个对象另外整数和短字符串等不可变对象在 Python 里本身有缓存机制放在元组里也不会产生额外开销。3.3 性能优势不该是你选元组的唯一理由尽管元组在创建和遍历上略微占优但我还是建议你把性能看作“附赠品”而不是“主菜”。原因是大多数 Python 应用瓶颈根本不在列表和元组的选择上而在输入输出、网络请求、数据库操作、算法设计这些地方。为省几个纳秒把代码里所有列表改成元组收益微乎其微还容易把自己坑了——因为你可能下一秒就想append。真正值得在选型时考虑的是“这组数据在生命周期内需不需要变化”。需要频繁增删改就别硬用元组然后不停拼接数据是固定记录就别硬用列表然后忘记“防改”。性能差异只在“大规模、长生命周期、高频读取”时才有实际意义比如缓存系统里的键组、报表的列头集合、坐标点集等。有一个经验可以分享如果你的数据只被读取、被哈希、被作为字典键、被解包、被传给一个只读函数那就大胆用元组。反过来一旦你需要从外部动态填入数据或者数据来源本身是不定长的那就老老实实用列表。先保证代码语义清晰再谈性能优化否则就是本末倒置。4. 场景选型什么时候该用元组什么时候无脑用列表选列表还是选元组其实没有标准答案但有很清晰的倾向性。我的习惯是先问自己一个问题“这份数据在我手里还会不会变化”答案会很快帮我做决定。下面把典型场景分门别类列出来方便你按图索骥。4.1 非用元组不可的场景先说“非它不可”的场景这些场景如果换成列表程序可能直接报错或行为出错。第一字典键或集合元素。字典键必须可哈希所以用元组是天然选择列表连门都进不了。实际业务里用(user_id, created_at)当键做缓存非常常见cache {} key (user_1001, 2025-06-01) cache[key] {page: 1}第二函数返回多值。Python 函数返回return a, b时本质是返回一个元组(a, b)。调用方可以直接解包def get_user(): name Alice age 28 return name, age name, age get_user()这个场景里元组是语法自带的默认行为没必要也不应该改成一个列表。第三接收“不可变记录”。比如数据库查询里一行记录包含 id、名称、创建时间这一行本身就是不可变事实用元组存最合适row (101, Python教程, 2025-06-01)你可能会想“记录里的名称以后也许要改”但请记住这里的元组代表“某一时刻的快照”真要改的时候你应该创建新快照而不是在原快照上动手脚。这跟数据库一行记录被 UPDATE 后产生新版本是一个思路。第四需要防止意外修改的配置项。有些全局配置数据只是让人读的谁改都危险。用元组能利用解释器的强制保护帮你挡住误操作。比如敏感的状态列表ALLOWED_STATUSES (pending, running, done, failed)哪怕团队里有人犯迷糊想ALLOWED_STATUSES.append(...)解释器也会立刻给他一条AttributeError提醒比运行时悄悄改出一个脏状态好多了。4.2 使用元组的进阶姿势具名元组元组虽然能存数据但拿手的是写位置row[0]、row[1]这样的写法可读性差。标准库里的namedtuple具名元组解决的就是这个毛病它生成了一个元组子类既保持元组的不可变、轻量、可哈希又允许通过名字访问字段。from collections import namedtuple Point namedtuple(Point, [x, y]) p Point(10, 20) print(p.x, p.y) # 10 20 print(p[0], p[1]) # 10 20兼容元组操作我在解析配置文件时就很爱用这种结构尤其是需要返回一组“带名字的固定字段”时比小写类省事比普通元组清晰。它无法修改字段还不会像普通元组那样把魔法数字下标散落一地。如果 Python 版本高于 3.7也可以用typing.NamedTuple配合类型注解后面我会单独讲。4.3 默认用列表的场景列表的使用场景远比元组多因为大多数业务数据都是动态构建的。下列几种情况选列表基本没错从文件、网络、接口逐行读取数据数据条数未知需要按条件追加、过滤、删除数据比如爬虫里攒一批 URL需要原地排序、反转、打乱顺序需要把多个来源的数据汇合到同一个集合里之后还要继续加工需要模拟栈append/pop或队列配合collections.deque结构给函数传参时希望函数能原地修改这个集合让调用方看到最新状态。举个典型的例子从 Excel 里读一堆用户 ID清洗掉空值和重复项再按某列排序这种从头到尾都在变的数据用列表是最自然的user_ids [] for record in raw_rows: uid record.strip() if uid and uid not in user_ids: user_ids.append(uid) user_ids.sort()换成元组反而别扭因为你每一次去重都要生成一个新元组复杂度高还难读。列表的生命周期就是“逐步构建 - 使用 - 最终交付”元组的形象则是“一次成型 - 保持不变 - 随处传递”。4.4 在“边界场景”里怎么判断总有一些处于中间地带的数据比如既不是固定记录也不是动态集合而是“确定不变但需要频繁操作”的数据。我的经验是仍然偏好元组尤其是数据量不大、且经常要传给若干个函数的场景。原因有两个。一是元组在多个函数间传递时不必担心某个函数“手贱”修改数据排查问题时少了很多“到底谁改了它”的猜测成本。二是元组可以直接做字典键如果你后续想知道“这组参数有没有被处理过”元组版本可以直接塞进集合去重processed set() for a, b in tasks: if (a, b) in processed: continue # 处理 (a, b) processed.add((a, b))如果是列表版本你就得把[a, b]先转成元组(a, b)才能放进集合。早早就用元组反而省事。还有一个小技巧可以帮你快速建立判断直觉看数据结尾是不是有“必然的最终形态”。一个订单中“商品名、单价、数量”三个字段一旦生成很少会再变这就能考虑元组甚至具名元组“购物车里的商品”则会一路增删改查明显更适合列表。5. 一手“坑”实操我踩过的三个列表与元组问题理论说多了容易飘直接看实战里最容易出事的几个情况。下面三个问题我都真实踩过或者说看别人踩过每一个都很有代表性。5.1 用列表当字典键导致 TypeError开头提起过我最早写坐标缓存时就吃了这个亏。当时需求的逻辑很简单处理两两之间的连接关系想用(起点, 终点)作为键来缓存计算结果。可我手一抖写成了[起点, 终点]bad_key [1, 2] d[bad_key] value # TypeError: unhashable type: list当时我还很困惑因为单独执行hash([1, 2])照样报错。后来想明白字典根据键的哈希值决定存储位置如果键在存储期间变了整个字典就废了。列表允许原地修改无法保证“键不变”所以 Python 干脆全盘禁止。解决方案要么是用元组要么把列表转成元组后再存edge_key tuple(edge_list) d[edge_key] value后来我在设计接口时形成一个习惯任何可能进入字典键或集合的信息在建数据结构的当时就声明为元组不要等将来再转换。转换操作本身不难但每次转来转去既影响可读性也会轻微拖慢程序。5.2 元组里藏着列表哈希又崩了比上一个更隐蔽的是你确实用了元组但元组里藏了列表结果照样报unhashable。比如record (1, 2, [3, 4]) d {record: ok} # TypeError: unhashable type: list很多人看到元组就以为万事大吉忽略了元组的哈希值是“递归计算”的。只要里面任何一个元素不可哈希整个元组就成了不可哈希对象。这个问题的排查方法也不复杂先试hash(record)如果抛异常再用递归或逐层判断确认哪一层混入了可变对象。真正遇到需要哈希存储的场景我会把内部列表改成元组或者在存储前统一做一次“不可变转换”def to_hashable(obj): if isinstance(obj, list): return tuple(to_hashable(item) for item in obj) if isinstance(obj, dict): return tuple(sorted((k, to_hashable(v)) for k, v in obj.items())) return obj这种防线在数据处理、缓存、去重逻辑里非常实用值得写进工具函数库。5.3 函数默认参数用列表所有调用共享同一份数据还有一个老生常谈但依然高发的坑函数默认参数用了[]。比如def add_log(message, log[]): log.append(message) return log print(add_log(a)) # [a] print(add_log(b)) # [a, b]而不是预期的 [b]问题根源是log[]在函数定义时只创建一次之后每次调用都复用了同一个列表对象。这个场景换成元组也不会好到哪去因为元组不可变你没法往里append正确的修法是def add_log(message, logNone): if log is None: log [] log.append(message) return log由此可见“可变”本身不是原罪“共享可变对象”才是。这里的教训是默认参数最好用不可变对象函数内部如果需要可变容器放在函数体里面初始化而不是放在签名里。5.4 顺手送一个直接比较列表和元组会得到 False我见过有人在测试里断言assert result (1, 2, 3)但result是从列表数据里切片得到的[1, 2, 3]于是断言一直失败。Python 的对序列类型比较时会先判断类型列表和元组永远不会相等。如果你只想比较“内容”必须先统一类型result [1, 2, 3] print(tuple(result) (1, 2, 3)) # True print(list((1, 2, 3)) result) # True这个问题虽然是基础中的基础但在类型混杂的接口数据里真的能浪费你一下午。提前知道能省很多事。6. 延伸到“更高阶”的玩法具名元组、类型注解与内存优化技巧到了最后一部分我想讲讲怎么把列表和元组的优势结合到工程化代码里。很多初学者学完区别就结束了但实际项目中还有一些非常常见的进阶用法。6.1 用 typing.NamedTuple 给元组加上类型和文档Python 3.6 起typing模块提供了NamedTuple它比collections.namedtuple更直观地支持类型标注。比如一个坐标类from typing import NamedTuple class Point(NamedTuple): x: float y: float label: str p Point(10.0, 20.0, A) print(p.x, p.y, p.label) # 10.0 20.0 A print(p._asdict()) # OrderedDict 形式输出这样做的好处是你仍然得到一个不可变的元组可以哈希、解包、比较大小但字段名带来的可读性让它完全不像“裸元组”。在写数据类但又嫌dataclass太重时NamedTuple是很好的中间选择。不过要注意NamedTuple不能完全替代数据类。如果数据需要经常修改某个字段NamedTuple的不可变反而碍事。这时用dataclass配合frozenTrue可以达到类似效果但那就不是元组本身了。6.2 列表推导式返回列表但你随时可以转成元组写 Python 时列表推导式是高频操作squares [x ** 2 for x in range(10)]如果你确定这个结果不需要再追加、修改直接转成元组可以节省一点内存还能防止后续误改squares tuple(x ** 2 for x in range(10))如果你用的是生成器表达式包一层tuple()比包一层list()在某些只读场景里更合适。例如要把一组唯一 ID 传给缓存校验元组可以直接做集合成员列表还得先转。6.3 元组解包在批量数据里的高效利用元组的另一个高频用法是批量解包。当你有一个“由元组组成”的列表时用for a, b in data的写法非常自然pairs [(A, 1), (B, 2), (C, 3)] for key, value in pairs: print(key, value)有时候你想丢弃某些位置的值可以用_占位for key, _, value in rows: ...这种模式在数据库查询结果、坐标集合、配置项遍历里太常见了几乎每天都会用到。元组的“结构固定”特性让它天然适合这种“按位拆包”的协议。6.4 也能用元组做轻量级“多返回值”数据载体不少函数有多个返回值尤其是从配置中心拉取“是否成功 错误码 详情”。用元组返回是最省事的方式但调用方读起来容易困惑。所以我会给“有多个返回值的函数”加一句文档说明或者直接换成具名元组。def load_config(path): ... return True, 0, {retry: 3} ok, code, data load_config(config.yaml)如果返回的字段超过三个或者调用方经常只取其中一两个字段考虑用NamedTuple会更优雅class LoadResult(NamedTuple): ok: bool code: int data: dict6.5 最后一个内存技巧优先考虑“不可变”结构做缓存与常量如果你的业务里有大量重复读取的常量数据比如内存里维护一份“省份城市映射表”每个城市名和邮编是固定配对我强烈建议用元组的元组CITY_MAP ( (北京, 100000), (上海, 200000), (广州, 510000), )把它设计成嵌套元组而不是嵌套列表一方面减少内存占用另一方面也是向读代码的人传递信号这不是一份可以被增删的“配置表”而是一份不可篡改的“静态字典”。即使哪天真要改城市列表你也得先写一个新元组整体替换这比悄悄append更利于 review。结合我自己的实际体验最舒服的状态是动态构建阶段用列表构建完成之后用元组数据以元组形式对外暴露内部如果需要加工再在函数内部临时转成列表。这样做既保住了性能也减少了意外修改数据带来的连锁问题。所以回到最初的问题列表和元组到底有什么区别从操作上讲是“能不能改”从内存上讲是“留不留余量”从场景上讲是“活页本还是塑封卡片”。多数时候你不需要纠结性能只需要想清楚“这份数据会变吗”。会变就用列表不会变用元组想两头的好处都占那就用具名元组。把这条线记牢比背下所有 API 管用得多。