资讯动态

Python列表与元组深度解析:可变性、内存分配与性能对比

发布时间:2026/10/7 3:34:19 来源:尧图企业网站定制
写这篇东西的起因是上周在群里看到有人问“列表和元组到底有什么区别”底下一堆人抢答“列表能变元组不能变”。这个答案没错但它远远不够。我在写 Python 的这几年里见过太多因为没吃透这两个基础结构而埋下的坑——有的藏在线上服务的偶发异常里有的藏在函数参数的默认值里有的干脆是性能瓶颈。列表和元组是 Python 里最基础的数据结构但你真问一句“它们到底差在哪、什么时候该用哪个”能说清楚的人其实不多。这篇文章我打算把列表和元组从底层实现到实际场景全部拆开讲一遍适合所有 Python 学习者不管你是刚入门还是已经写了两三年代码应该都能找到点之前没注意到的内容。1. 列表和元组到底差在哪先搞懂“可变性”这个核心词1.1 可变性不是“能不能改”这么简单先给新手一个最直观的结论列表list用方括号[]定义可以随意增删改元素元组tuple用圆括号()定义一旦创建它本身的长度和元素指向就不能再变了。lst [1, 2, 3] lst[0] 100 # 合法 lst.append(4) # 合法 tup (1, 2, 3) tup[0] 100 # 报错 TypeError tup.append(4) # 不存在这个操作直接 AttributeError但如果你只停留在“能不能改”这个层面那和背概念没什么区别。真正重要的是Python 解释器对“可变”和“不可变”对象的处理方式完全不同这会影响内存分配、哈希计算、参数传递甚至多线程安全。拿我最常举的一个比喻来说。列表就像是一个可以随意加座位的餐厅包间客人多了加把椅子客人少了撤掉几张包间的门牌号内存地址始终不变。元组则像一桌已经定好的固定套餐菜品数量在下单那一刻就锁死了你想换菜不行只能重新下一单。这个比喻背后是真实的内存机制。列表在创建时Python 会按需预留一块连续的内存空间而且当空间不够时它会自动申请更大的内存块并把旧数据搬过去这就是动态扩容。元组则完全不同它在创建的那一刻就一次性把内存空间定死了不多不少所以它不需要预留额外空间也没有扩容机制。稍后我会在性能部分详细展开这里先记住一个关键结论列表付出了“可变的代价”元组换来了“不可变的确定性”。1.2 内存布局差异带来的连锁反应如果深入一点看列表和元组的底层结构也有实质区别。CPython 里列表对象的核心是PyObject** ob_item说白了就是一个指针数组数组里每个元素都指向实际的对象。元组对象则是PyTupleObject它的底层同样是PyObject* ob_item[1]但关键区别在于元组在创建时会精确计算需要的空间一次分配完成。这意味着两件事。第一列表的空间利用率不是 100%。Python 为了减少扩容次数通常会让列表的实际分配容量大于当前元素数量。你可以自己验证一下lst [] for i in range(100): lst.append(i) if i % 20 0: print(i, sys.getsizeof(lst))你会发现列表的占内存大小不是线性增长的而是在某些容量点突然跳一大截。这就是扩容机制在工作。元组则不存在这个问题sys.getsizeof((1, 2, 3))永远是那个数。第二列表经常需要“复制数据”。当列表扩容时旧内存块的元素要逐一搬到新内存块。如果你的代码里反复对一个大列表进行 append 操作这个复制成本会被不断放大。元组没有这种烦恼创建完就固定永不搬家。我自己实测过创建一个包含 100 万个整数的列表大约需要 8MB 左右内存同样内容的元组大约只要 8MB 少一点——列表因为预分配机制通常比等长元组多占用一点内存。单个结构差距不大但如果你在内存受限的服务里用了成千上万个列表这个差距就相当可观了。2. 什么时候该用元组语义与安全的两层考量2.1 元组是“不可变记录”不是“不能改的列表”这是很多人理解上的最大偏差。他们认为元组就是阉割版的列表因为不能修改所以功能更弱。大错特错。元组在 Python 里真正的定位是“不可变记录”——一个结构化的数据载体用来固定表达一组相关但不一定同类的数据。举个例子一个三维空间的坐标点(x, y, z)一个 RGB 颜色值(255, 255, 0)一条数据库查询结果的单行数据(id, name, age)这些都是典型的元组场景。它们的共同特征是什么字段数量固定、字段顺序有意义、每一个位置上的数据都承载特定语义。这时候如果你用列表语义就模糊了。[255, 255, 0]到底是一个颜色还是一个长度可变的数据集合没人知道。但(255, 255, 0)配合解构赋值一眼就能看出是三个分量r, g, b (255, 255, 0)所以我的经验法则是当你想表达“这个数据的结构是预先确定的每个位置都有固定含义”时用元组当你想表达“这是一个可以不断增删改的动态集合”时用列表。一句话总结列表强调“数量会变化”元组强调“结构不会变”。2.2 元组的哈希能力带来的特殊用途元组不可变因此它是可哈希的hashable可以当作字典的键或者放进集合里。列表因为可变、不可哈希做不了这两件事。这是元组最实用但也最容易被忽视的用途。# 合法元组作为字典键 point_map {(0, 0): origin, (1, 1): first_point} # 非法列表作为字典键直接 TypeError: unhashable type: list # point_map_bad {[0, 0]: origin}这个特性在算法题和实际项目里都非常有用。比如你要统计二维网格中每个坐标点的访问次数用元组做键是自然而然的选择。再比如你要用记忆化递归解决动态规划问题带缓存的时候就需要把状态参数打包成元组来当作functools.lru_cache的键。还有命名元组namedtuple这是集两者之长的产物。它像元组一样不可变、可哈希又像对象一样可以通过字段名访问属性from collections import namedtuple Point namedtuple(Point, [x, y]) p Point(10, 20) print(p.x, p.y) # 10 20如果你需要一个轻量的数据对象又不想写一整个 classnamedtuple或dataclass都是比裸元组更好的选择。处理大规模数据的时候大量使用namedtuple可以减少属性赋值的开销同时保留不可变带来的安全性。2.3 元组在函数参数和返回值中的隐藏价值函数传参这块元组和列表的差异同样值得注意。Python 的函数参数传递本质是“传对象引用”不是传值也不是纯传址。当你把列表传给一个函数这个函数可以原地修改列表内容调用者那边也会感受到变化。元组因为不可变天然避免了这个风险。def append_item(container, item): container.append(item) lst [1, 2, 3] append_item(lst, 4) print(lst) # [1, 2, 3, 4] 被改了这种“隐式副作用”是很多线上 bug 的来源。如果你希望函数绝对不修改传入的数据把数据声明为元组是最简单粗暴的防御手段。我写公共库的接口时凡是不需要被调方修改的参数能传元组就尽量传元组。还有返回值。当一个函数要返回多个值时Python 最常见的写法是return a, b, c这个语法本质上是返回一个元组。解构赋值的时候我们写a, b, c func()其实就是序列解包。这种情况下如果返回值想表达“一组不同的信息”元组就是天然正确的选择根本不需要再手动打包成列表。3. 列表的用武之地动态数据的首选3.1 列表推导式列表最“Pythonic”的用法列表当然不是只能当“数据仓库”。它最强大的一面在于配合推导式list comprehension和生成器表达式完成数据的批量加工。先看一个常见场景从一堆商品数据里筛选出价格大于 100 的商品名称。names [item[name] for item in products if item[price] 100]这个写法比传统的 for 循环加 append 更简洁、更高效。为什么更快因为列表推导式在 CPython 里会被特殊优化它省去了频繁的LIST_APPEND字节码调用直接以底层指令快速构建列表。我自己测过同样条件下列表推导式通常比 for 循环快 20%~30%差别随数据量增大而更加明显。不过这里有个新手容易踩的坑如果你只是要对列表里的每个元素做一个简单操作然后收集结果用推导式没问题但如果操作本身有复杂的副作用、有异常处理需求、或需要多行逻辑硬压缩成推导式只会让代码变成天书。可读性永远是第一位的。我见过有人把 15 行的处理逻辑硬塞进一个四层嵌套的推导式里那种代码维护起来是真的要命。还有一个细节列表推导式生成的是全新列表它不会修改原列表。如果你需要保留原列表做后续操作注意别“覆盖”了原变量。lst [1, 2, 3] lst_squared [x * x for x in lst] # 原列表不变 # 如果你真的想原地改老老实实用循环 for i in range(len(lst)): lst[i] lst[i] * lst[i]3.2 列表的切片操作不只是“复制一份”列表切片可以说是 Python 中最常用的操作之一但切片里的门道不少。最基本的lst[start:stop]返回一个新列表包含从 start 到 stop-1 的元素。这个“新列表”意味着它和原列表共享元素对象——对切片结果的修改不会影响原列表但切片里的对象本身仍然是同一批对象的引用。lst [1, 2, 3, 4, 5] sub lst[1:4] # [2, 3, 4] sub[0] 99 # 原列表 lst 不变 print(lst) # [1, 2, 3, 4, 5]切片的高级玩法还包括步长lst[::2] # 每隔一个取一个等价于取偶数索引 lst[::-1] # 反转列表lst[::-1]这个用法很多人知道但未必清楚它内部就是把 start 和 stop 都省略了然后步长为 -1于是列表从尾向头遍历。这是反转列表最 Pythonic 的写法比list(reversed(lst))快且简单。还有一个容易出错的地方用切片插入或替换元素时它和直接的索引赋值的语义不同。lst [1, 2, 3, 4, 5] lst[1:3] [9, 8, 7] # 把索引 1 和 2 替换为三个元素列表变成 [1, 9, 8, 7, 4, 5]如果你不小心把[9, 8]写成了数字 9会直接报TypeError因为切片赋值需要可迭代对象。这个坑在动态处理数据时非常常见。关于切片我还想特别提一下记忆点切片产生的是浅拷贝shallow copy。什么意思如果列表里的元素本身就是可变对象比如嵌套列表、字典那么切片得到的新列表里这些可变对象仍然是同一批对象。修改其中的某个嵌套元素会同时影响原列表和切片结果。lst [[1, 2], [3, 4]] sub lst[:] # 浅拷贝 sub[0][0] 99 # 修改的是嵌套列表内部 print(lst) # [[99, 2], [3, 4]] 原列表也被改了如果需要彻底复制一份也就是深拷贝得用copy.deepcopy()。这个知识点在写数据处理、矩阵操作时非常关键。4. 性能与内存对比别被直觉骗了4.1 创建、索引、遍历的性能差异很多文章会告诉你“元组比列表快”然后就没有然后了。到底快多少、在哪个操作上快、差值有没有实际意义这些不讲清楚读者只会得到一个模糊的印象。我自己在 CPython 3.11 上做过简单基准测试结论如下创建元组比创建列表快。因为元组不需要额外的扩容管理空间一次分配到位。索引访问两个几乎没有差别。lst[0]和tup[0]都是 C 层面的数组取值。遍历速度元组略快。原因是底层结构更紧凑缓存命中率更高。元组作为字典键时哈希计算比同等内容的不可变的自定义对象要快。但这些差距通常非常微小除非你在循环里创建百万级别的元组/列表否则差别可以忽略。真正需要关注的性能差异不是“访问”而是“复制”和“扩容”。列表的append操作虽然平均复杂度是 O(1)但扩容那一刻会有一次 O(n) 的搬移。如果你预先知道列表最终会有多少元素建议在创建时预留容量。可惜 Python 没有像 Java 的ArrayList(int initialCapacity)那样直接指定初始容量的标准接口但你有一个取巧方式lst [None] * expected_size # 然后通过索引赋值 lst[i] value避免反复 append 触发扩容这样做能显著减少扩容开销特别是在数据量很大的时候。我自己写大规模数据处理脚本时经常用这个办法把内存分配的次数降下来。元组还有一个性能相关的优势同一个元组对象可以被多个地方安全共享。因为不可变Python 内部可以做很多优化——比如小整数和短字符串会被缓存相同内容的元组有时也能直接复用。列表则不行一旦被共享就可能被其中一方修改解释器不敢自动共享。4.2 从源码层面看列表和元组的增长策略如果你有兴趣深入了解可以读读 CPython 源码里listobject.c的扩容逻辑。核心机制是当列表容量不足时Python 会按下述规则计算新容量new_allocated (newsize 3) (newsize 9 ? 3 : 6) newsize一句话总结就是新的分配容量大约是所需元素数量的 1.125 倍再加上一个常数。这个策略是为了平衡两件事——减少扩容次数避免频繁搬移和减少内存浪费不要预留太多空间。元组没有这些复杂逻辑。tupleobject.c里创建元组时直接根据参数数量n分配n个指针的空间一步到位。没有扩容没有预留没有搬移。也正是因为这种“一次成型”的特性元组的创建效率才会高于列表。理解这些底层逻辑对调优有很大帮助。比如你知道列表 append 偶尔会触发 O(n) 的搬移所以在高实时性的路径里你可以提前一次性地构建好列表而不是反复 append。知道元组创建即定型那么在初始化大量“记录型数据”时用元组不仅语义正确内存上也更紧凑。5. 常见问题与排查技巧实录5.1 元组里套列表不可变的“漏网之鱼”这是我在实际答疑中见到频率极高的问题既然元组不可变为什么下面这段代码没报错t (1, 2, [3, 4]) t[2].append(5) print(t) # (1, 2, [3, 4, 5])很多人看到这就懵了。实际上元组的不可变指的是“元组自身结构不可变”——它包含的元素的引用不能增加、删除、替换。但元组里的某个元素如果本身是可变对象比如列表、字典、集合那个对象内部依然是可以被修改的。用我的比喻再说一遍元组是一个固定大小的盒子盒子里放了一个球球上写着数字。你不能把球换成别的球但你可以用笔把球上的数字改掉。球还是那个球球上的内容却变了。这个案例也给了我们一个警告如果你在元组里放了列表并且依赖于“元组不可变”来保证数据安全那你的防线实际上是漏的。如果需要真正深的不可变应该用tuple加不可变元素或者自定义一个不可变的数据结构。5.2 常见疑惑速查表操作或场景列表元组定义方式[1, 2, 3](1, 2, 3)能否新增/删除元素可以不行能否修改已有元素可以不行嵌套可变对象除外是否可哈希能做字典键不行可以适合表达同质、数量动态变化的数据异质、结构固定的记录内存占用相对更大预分配相对更小当作参数传入函数可能被函数修改安全、不会被改遍历/索引速度略慢几乎无感略快这个表我建议直接存下来。面试也好、日常写码也好遇到不确定的时候拿出来看两眼比死记硬背强得多。5.3 五个实操中踩过的坑第一个坑函数默认参数用了列表。def add_item(item, cache[]): cache.append(item) return cache这个默认列表只会被创建一次后续所有调用都共享它。结果就是函数行为像 global 变量一样数据互相污染。解决方案是默认值写成None函数内部再创建def add_item(item, cacheNone): if cache is None: cache [] cache.append(item) return cache第二个坑误用列表做大量 hash 查询。很多人用if x in lst来判读元素是否来自某个集合。列表的in操作是 O(n)数据一大多就很拖慢。这时候应该用set或把 key 做成元组放入集合。# O(n)数据量大时卡顿明显 if target in large_list: ... # O(1)改成集合 if target in large_set: ...第三个坑切片和copy()混淆。刚才已经说了切片是浅拷贝。有人以为lst[:] lst或者lst_new lst[:]就是深度复制结果修改嵌套元素时发现老数据也被改排查半天。涉及嵌套结构时务必使用copy.deepcopy()。第四个坑循环里不断拼接列表。result [] for chunk in data: result result chunk这种写法每次都会创建一个新列表把旧数据全部复制一遍时间复杂度从 O(n) 退化到 O(n^2)。应该用extend或append。第五个坑忽略元组的创建语法细节。单个元素的元组必须加逗号(1,)。不加逗号的话(1)只是一个整数旁边的括号。同时也要注意1, 2, 3这样不加括号的形式也会被识别为元组。很多新手在函数返回值里踩过这个坑。5.4 排查思路分享当你遇到一个“莫名其妙”的 bug比如数据在函数调用后被修改、多次调用同一个函数结果不一样、性能突然卡顿不妨先花两分钟检查一下涉及的数据结构是列表还是元组。我自己的排查顺序是这样的先看数据有没有被共享引用可以用print(id(x))来确认两个变量是否指向同一对象再看有没有对传入的列表做原地修改最后再检查有没有用可变对象作为默认参数或全局变量。这套流程下来80% 的“诡异 bug”都能水落石出。还有一种情况是从数据库或外部接口拿到的“列表”你以为它是动态的结果数据量固定、语义固定。这时候回头想想把数据转成元组传给下游既安全又能稍微省点内存。6. 列表与元组之外拓展思路6.1 从列表和元组到其他序列类型弄懂了列表和元组其实就掌握了一多半 Python 序列类型的设计思路。比如str是一种特殊的不可变序列range是惰性序列bytes也是不可变字节序列。理解了“可变与不可变”“动态与固定”这些核心维度你可以举一反三快速理解其他数据结构的约束和用途。Python 的collections.abc里还有MutableSequence和Sequence两个抽象基类。列表属于前者元组和 range 属于后者。如果你想自己实现一个自定义序列实现这些 ABC 的接口是最规范的做法也能自动获得in、index、count等方法的默认实现。6.2 从“用对结构”到“数据结构思维”很多人写代码的毛病是不管什么数据都往列表里塞。一个用户的信息用列表存一个城市的温度序列用列表存一个坐标点也用列表存全都塞进[]里。时间一长代码里充满了data[0]、data[1]这种“魔法数字索引”可读性极差。这时应该想到元组、namedtuple、dataclass甚至字典。每种数据结构都有自己的“表达能力”和“约束能力”选对结构是让代码自解释的第一步。我在代码评审中最常说的一句话如果你的函数返回了一个列表但调用方无论什么情况下都需要它保持原来的顺序和长度那这个返回值其实应该是一个元组甚至在更复杂的情况下是一个自定义类或字典。反过来如果你的列表只是临时收集数据最终还要过滤、排序、追加那列表就是正确的选择。数据结构选择的思维不只是在 Python 里重要。你在任何一个语言里写代码本质上都是在操弄数据结构。把列表和元组这对“双生兄弟”彻底弄懂培养的是对整个数据类型体系的掌控感。之后再学字典、集合、队列、栈、堆就会顺畅得多。最后说一点个人体会。我在实际开发中并不会做到“严格不用列表或抽象手写元组”。该用列表的时候绝不含糊该用元组的地方也绝不为那点性能去硬拆。真正让我受益的是对每一行代码背后的数据流有一个清晰的判断这个数据是可变的还是固定的是动态集合还是结构记录共享安全吗性能敏感吗想清楚了内存、性能、bug 率全部受益。希望你也能通过这篇文章把这对基础结构踩踏实。

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

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

免费获取报价 →
↑