ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python列表与元组的区别:可变性、内存与性能实战解析

Python列表与元组的区别:可变性、内存与性能实战解析 Python里有两类长得特别像的数据结构一个叫列表一个叫元组。我带过的每一个新人几乎都会问我一模一样的问题它俩到底有什么区别哪个更好用说实话刚接触这个知识点的时候我也觉得它们之间的界限有点模糊。列表写成[1, 2, 3]元组写成(1, 2, 3)只是括号不同又能互相转换方法也高度重合看起来就像一对双胞胎。但这个像字恰恰是最坑人的。列表和元组的本质差异会直接影响到你代码的内存占用、运行速度、设计思路甚至决定一段程序在特定场景下会不会崩溃。搞懂它们不只是记住列表可变、元组不可变这一句话而是要理解这个区别背后的连锁反应并且在写代码的时候能条件反射地判断出这个场景该用哪一个。这篇文章我就把自己对这些知识的理解、踩过的坑、以及常用的判断方法一次性讲清楚。1. 列表和元组一对亲兄弟性格却截然不同1.1 相同点它们都是序列天生具备一堆共同本领在 Python 的世界里列表和元组被归为一大类——序列类型。也就是说它们内部元素有先后顺序可以通过位置序号索引来访问。lst [Python, 3.12, [1, 2]] # 列表用方括号 tup (Python, 3.12, [1, 2]) # 元组用圆括号 # 访问、切片完全一致 print(lst[0]) # Python print(tup[0]) # Python print(lst[1:]) # [3.12, [1, 2]] print(tup[1:]) # (3.12, [1, 2]) # 迭代、成员判断、计算长度也一致 for item in tup: pass print(len(lst)) # 3 print(Python in tup) # True它们还能存放任意类型的混合数据。一个列表里可以塞整数、字符串、字典甚至另一个列表元组同理。这也让很多初学者迷惑既然都能做同一件事为什么不统一用一种答案就在可变这两个字上。1.2 本质差异可变与不可变一句话说清列表是可变对象。你可以随时往列表里追加元素、删除元素、替换元素列表的身份不会改变变的是它内部的内容。元组是不可变对象。一旦创建完成你没法追加元素、没法删除元素、没法替换元素。试图这么做Python 会立刻抛异常。lst [1, 2] lst.append(3) # 没问题lst 变成 [1, 2, 3] lst[0] 100 # 没问题lst 变成 [100, 2, 3] tup (1, 2) tup.append(3) # AttributeError: tuple object has no attribute append tup[0] 100 # TypeError: tuple object does not support item assignment我把这个区别比作草稿纸和便利贴。列表是一张可以反复涂改的草稿纸写错了擦掉重写内容随意变。元组是一张写好的便利贴贴上去之后你就不能改上面的字了想改只能撕掉重新写一张新的。这个类比虽然简单但能解释很多后续现象。值得提醒的是元组并不是完全没有操作方法。它内置了count()和index()可以统计某个元素的出现次数和查找位置只是不能改变自身内容。而且 Python 还允许直接对元组做和*运算不过这两个操作返回的是一个新元组不是修改原来的元组。t1 (1, 2) t2 t1 (3, 4) # (1, 2, 3, 4)t1 还是 (1, 2) t3 t1 * 2 # (1, 2, 1, 2)t1 还是 (1, 2)这个特性很关键元组一旦生成它的结构就固定了任何变化都意味着创建新对象。2. 不只差一个可否修改内存、哈希、性能的连环效应可变与不可变是根本但它带来的副作用远比你想象的多。很多人在学完第一步就停下来了结果后面遇到内存分析、字典键选择、性能调优时又开始懵。这里我把三个最直接的连锁效应展开讲。2.1 内存策略列表会预支元组在精打细算列表为了实现append、insert等动态操作底层采用了一种动态数组的存储方式。它不会每次只分配一个元素的内存而是预先多分配一些空间等空间快用完时再一次性扩大。这个扩大的过程大约是按当前容量的 1.125 倍增长。这种设计的好处是append操作很快平均下来几乎只要 O(1) 的时间。坏处是内存会被浪费一部分——明明列表里只有 3 个元素底层可能已经占了 8 个元素的位置。元组完全相反。它的长度固定创建时就精确分配内存不多占一个字节。而且 CPython 底层还有一个小元组缓存池机制对长度不超过一定范围的元组销毁后不会立刻还给操作系统而是存进缓存备用下次创建同样长度的元组时直接复用这块内存。我经常在项目里用sys.getsizeof()对比两者import sys lst list(range(100)) tup tuple(range(100)) print(sys.getsizeof(lst)) # 例如 920含预分配空间 print(sys.getsizeof(tup)) # 例如 856精确分配不同 Python 版本下具体数值会有差异但结论是稳定的同样数据元组的内存占用更小。如果一个程序里要创建成千上万个不会改变内容的小结构把它们从列表换成元组内存上的收益非常明显。2.2 哈希能力为什么元组能当字典键列表不行Python 里有一类对象被称为可哈希对象它的标志就是能传给内置函数hash()并得到一个固定的哈希值。可哈希有意义的前提是对象创建后内容不能变。你想想如果内容变了哈希值也得变那之前把数据放进字典、集合时算出来的位置就全乱了。列表是可变的所以它不可哈希hash([1, 2, 3]) # TypeError: unhashable type: list元组在绝大多数情况下是可哈希的hash((1, 2, 3)) # 返回一个整数比如 529344067295489451这个特性直接决定了元组可以充当字典的键、集合的元素而列表不行。d {} d[(2025, product)] 数据分析 # 复合键元组 # d[[2025, product]] 数据分析 # 这里会直接报错 s {(1, 2), (3, 4)} # 元组作为集合元素 print(s)但这里有一个极其容易踩的暗坑包含列表的元组同样是不可哈希的。因为元组只是保证自己对元素的引用不可变并不保证引用的东西本身不可变。如果元组里放了一个列表这个列表的内容会变元组的哈希基础就被破坏了。tup ([1, 2], abc) hash(tup) # TypeError: unhashable type: list这个细节在下面避坑章节还会专门展开。2.3 性能对比元组一定更快吗实测给你看直觉上元组因为不可变、结构简单应该全面比列表快。这句话大体对但要看具体操作。我本地实测过的几类常见操作结论是这样的tuple(range(1000000))比list(range(1000000))略快因为列表要预分配额外容量。遍历 1000 万元素的耗时元组比列表快那么一丢丢通常差距在个位数百分比以内。这是因为元组的底层数组更紧凑迭代时 CPU 缓存命中率更高。但随机索引访问、切片等操作两者差距微乎其微几乎可以忽略。删除元素、插入元素这类操作列表依然要移动后续元素元组则根本没有这类操作。所以我的判断是为了性能而去刻意把列表改成元组在绝大多数业务场景里属于费力不讨好。收益太小代码可读性反而可能下降。真正值得用元组的地方是语义上它本来就不该被修改。性能应当是你在两个结构之间犹豫不决的时候最后一个考虑因素而不是第一个。3. 应用场景决策什么样的代码该用列表什么样的该用元组搞清楚了原理下一步就是落实到具体编码。我在实际项目里总结了一套选择方法不一定绝对但对日常开发非常实用。3.1 优先选元组的四个高频场景第一个场景函数返回多个值。Python 的函数天然支持返回多个值实际上返回的是一个元组。def get_user_info(): return 张三, 28, 北京 info get_user_info() print(type(info)) # class tuple这种用法背后已经是元组了你没必要再手动把它包成列表。第二个场景*args可变参数。定义函数时如果用*args收集多余的位置参数Python 会自动把参数组合成一个元组。def foo(*args): print(type(args)) # class tuple foo(1, 2, 3)因为调用方传入的参数是一次性、不该篡改的元组正好合适。如果你在函数内部不小心对args做了修改那很可能是逻辑写错了。第三个场景固定配置、常量、坐标点这类只读数据。比如一个服务的主机地址三元组IP、端口、协议、一个 RGB 颜色值、一个地图坐标(经度, 纬度)。这些数据的共同特点是一变就错。RED (255, 0, 0) API_ENDPOINT (api.example.com, 443, https)用元组给这些常量表态这个数据不允许被动态修改。代码审查的时候别人一眼就能看出意图。第四个场景字典的键和集合的元素。如果需要用多个字段组合成一个复合键比如用(用户ID, 商品ID)作为字典键来存购物车信息那元组就是专门为这种场景准备的。3.2 优先选列表的三个典型场景第一个场景数据量不确定、需要不断追加或删除的动态集合。比如从数据库里分批捞用户记录每捞一批就extend()一次最后统一处理。这种场景列表天然合适。all_items [] for page in range(1, 10): batch fetch_page(page) # 每次返回一批 all_items.extend(batch)第二个场景需要就地排序、反转、替换元素的集合。列表提供了sort()、reverse()、pop()这一大票就地操作方法元组完全没有。如果你要写一个事件处理队列窗口里反复取出第一条、追加最新一条那列表顺手得多。第三个场景作为中间容器进行数据处理。从元组生成列表做临时计算或者把某段文本按行切分成列表再逐行处理这种临时性、可变的中间容器用列表更灵活。比如列表切片切出的数据往往还要进一步操作用列表比较自然。3.3 一招判断问自己两个问题就够了我在给基础班同学讲这个知识点的时候会让他们在面对选列表还是元组时只问自己两个问题这份数据我之后会不会修改它的大小或内容这份数据会不会被拿来当字典键、放进集合第一个问题答会选列表答不会看第二个问题。第二个问题答会必须选元组。第二个问题答不会那再看看语义和性能一般优先选元组因为它更省内存、更防误改。下面这张表是我常用的速查表典型需求推荐类型一句话原因函数返回多个值元组Python 语法天然生成元组*args参数元组解释器固定行为字典复合键元组可哈希内容稳定固定配置、常量、坐标元组防止误改节省内存动态收集数据列表需要 append/extend栈、队列操作列表尾部增删方便就地排序、反转列表只有列表有 sort/reverse批量数据切片处理列表切片结果用列表更灵活这套判断逻辑我用的时间超过五年基本上没出过岔子。它最值得推荐的地方是你不需要背场景只需要判断两个性质。4. 实战避坑列表与元组常见的五个隐形地雷即使掌握了上述判断规则实际写代码时仍然有不少地道陷阱。下面这五个坑每一个我都见过不止一次甚至自己也踩过。4.1 默认参数写成 [], 函数第二次调用就翻车这是 Python 默认参数最著名的坑没有之一。def add_item(item, container[]): container.append(item) return container print(add_item(1)) # [1] print(add_item(2)) # [1, 2] 翻车了很多人以为每次调用函数都会新建一个空列表但实际上默认参数只会在函数定义时求值一次这个列表对象就被永久绑定在函数上了。第二次调用时container拿到的还是同一个对象里面已经有一个元素了。正确写法是用None作为占位def add_item(item, containerNone): if container is None: container [] container.append(item) return container理解了可变对象在函数参数中的这种共享引用行为后你会发现很多隐蔽 bug 都能提前规避。4.2 元组里藏列表不可变变成了可变元组不可变指的是元组这个容器不能增删元素、不能替换元素但元组里的元素本身如果是可变对象它的内部状态可以变。tup ([1, 2], abc) tup[0].append(3) # 没报错 print(tup) # ([1, 2, 3], abc)这就像一个密封的收纳盒盒子本身打不开不能往里面塞新物件但盒子里的小抽屉如果本来就是打开的抽屉里的东西当然可以动。解决方法是如果你真的需要一个完全不可变的结构就确保元组内每个元素都是不可变类型或者用前面提到的namedtuple、frozenset这类更加严格的不可变结构。4.3 用 * 复制列表复制的只是引用列表是可以做乘法运算的[0] * 3得到[0, 0, 0]看起来很合理。但当你用一个二维列表做乘法时灾难发生了matrix [[0] * 3] * 3 matrix[0][0] 1 print(matrix) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]]原因在于[[0] * 3] * 3创建了三个指向同一个内部列表的引用。修改其中任意一个所有行都跟着变。正确做法是用列表推导式每次迭代都创建新列表matrix [[0] * 3 for _ in range(3)] matrix[0][0] 1 print(matrix) # [[1, 0, 0], [0, 0, 0], [0, 0, 0]]这个坑的本质还是可变对象共享引用的问题。只要是列表这种可变对象复制时都要想清楚这是深拷贝还是浅拷贝内存里到底有几份数据4.4 切片切出新列表别把它当视图列表的切片返回的是一个新列表不是原来列表的视图。这一点和很多语言不一样我记得第一次学的时候特别容易误判。original [1, 2, 3] sliced original[:] sliced.append(4) print(original) # [1, 2, 3] 没受影响 print(sliced) # [1, 2, 3, 4]利用这个特性lst[:]经常被用来快速浅拷贝一个列表。但要注意浅拷贝只拷贝外层如果列表元素里还有嵌套列表内层依然是共享引用。另外元组切片得到一个新元组同样不影响原元组tup (1, 2, 3, 4, 5) sub tup[1:3] # (2, 3)而在需要颠倒列表顺序但不改原列表时lst[::-1]这个技巧也很常用它能快速生成倒序副本。4.5 实用技巧拆包、交换变量、配合 enumerate 遍历聊完坑我再分享几个让列表和元组变得更好用的技巧。这些在业务代码里出现频率极高。多个变量的同时赋值本质就是元组拆包。a, b 1, 2 # 右侧 (1, 2) 是元组左侧做拆包 a, b b, a # 交换变量一行搞定遍历时同时拿索引和值用enumerate。lst [a, b, c] for i, value in enumerate(lst): print(i, value)它返回的是索引和元素组成的元组序列天然适合拆包循环。元组同样可以用这种写法做枚举元组。用星号拆包取中间任意部分。first, *middle, last [1, 2, 3, 4, 5] print(first) # 1 print(middle) # [2, 3, 4] print(last) # 5这种写法对列表和元组都适用能把很多繁琐的索引运算简化掉。5. 最后说点我的真实体会这些知识看起来基础但我在实际项目里见过太多因为用错类型而导致的 bug。最典型的两种一是在不该修改的数据结构上调用修改方法二是用列表去当字典键然后收到TypeError。其实都是对可变性理解不到位。我个人在项目中的约定是所有从接口返回的只读快照一律用元组所有需要动态聚合、排序、批量处理的数据一律用列表。如果某个模型数据要同时具备不可变和字段名两层语义我会直接用typing.NamedTuple或collections.namedtuple。举个例子坐标点我可能会写from typing import NamedTuple class Point(NamedTuple): x: float y: float它既保留了元组的不可变、可哈希、省内存特性又能像对象一样访问字段名比裸元组舒服得多也比普通类省去大量样板代码。最后再分享一个小技巧当你实在拿不准选哪个的时候先写列表等代码能跑通再回头看有没有哪块数据本质上不该被修改。如果有静下心把它改成元组你会立刻感觉到代码的脾气都不一样了——很多不该发生的误操作会在第一次写出来的时候就被 Python 用异常拦住。这种提前暴露问题的安全感正是元组带给我的最大价值。
返回列表