尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python列表与元组深度对比:可变性、性能与实战选型指南

Python列表与元组深度对比:可变性、性能与实战选型指南 1. 先搞清楚本质列表和元组到底是什么在 Python 里列表和元组几乎是最常用的两种内置数据结构。很多新手学到这里都会觉得有点绕都能存数据都能下标访问语法上就差一个方括号和圆括号凭什么要分两种东西这篇我直接用大白话把这两个家伙的底细扒清楚包括它们的区别、各自的适用场景以及我实际写代码时的一些取舍经验。先说结论列表是可变的元组是不可变的。这句话看着简单但它是理解一切区别的源头。1.1 可变与不可变是一切区别的根源可变mutable和不可变immutable是什么意思我用生活中最通俗的例子解释。列表就好比你手里拿着一张购物清单你可以随时在上面加东西、划掉东西、修改某一行。这份清单本身是你的一张纸内容可以随便改纸还是那张纸。元组呢更像你在咖啡馆下单后拿到的那张小票。打印出来是什么就是什么你不能手动在小票上把“美式”改成“拿铁”再拿回去要求店员按这个做。小票的内容固定不变。这个差异在内存层面怎么体现当你创建一个列表Python 给它分配一块内存这块内存里存的是“指向各个元素的引用”的数组。当你往列表里 append 一个元素它可能会扩展这块内存把新元素的引用塞进去。当你修改list[0] x它只是把数组中的第一个引用指向了新的对象。列表对象本身的id()始终不变变的只是它的内部内容。元组的底层结构也是一个存放元素引用的数组但是 Python 解释器明确保证了一旦元组创建完成你没有任何办法修改这个数组中任何一个槽位。没有 append、没有 extend、没有__setitem__、没有__delitem__。你只能通过重新赋值的方式让变量指向一个全新的元组但原来的那个元组对象永远停留在创建那一刻的状态。别小看这个“不可变”它直接决定了元组可以被哈希、可以被放进集合、可以做字典的键。而列表因为内容会变哈希值无法稳定所以 Python 直接禁止它作为字典键。这就是为什么你写{ [1,2]: a }会直接报TypeError: unhashable type: list。1.2 元组不只是“不可变的列表”很多人把元组理解成“不能修改的列表”这个说法没错但不够完整。元组在一些细节上跟列表有本质性差异尤其是**解包unpacking**能力。Python 的元组支持非常优雅的解包语法point (3, 4) x, y point列表同样支持解包point [3, 4] x, y point但元组在语义上天然就是“一组固定数量、各自有含义的值”而列表在语义上更像“一堆同类型、数量不定的数据”。比如坐标(x, y)、RGB颜色(255, 0, 0)、一个人的(name, age, city)这些就是元组的主场。而一个班级所有学生的分数、一篇文章的所有单词这些是列表的主场。再深入一层元组的不可变性还带来一个关键优势——它可以作为字典的键也可以放进另一个集合里。比如你想用坐标点作为键来存储地图上每个点的温度temperatures {} temperatures[(116.4, 39.9)] 25 temperatures[(121.47, 31.23)] 28如果 key 用列表上面的代码直接报错。这一条规则在实际开发中非常常见尤其是处理坐标、日期区间、组合键这种场景。很多人还会忽视一点元组的不可变是深度的还是浅度的答案是浅度不可变。元组本身不能增删改元素但如果元组里的某个元素本身是可变对象比如列表或字典那么这个可变对象的内部内容是可以被修改的。这一点我后面有一整个段落专门讲因为它是新手最容易踩的坑之一。2. 操作细节对比从创建到切片搞清楚概念后我们来看实际操作。列表和元组在日常使用中的操作差异主要集中在“修改类操作”和“性能表现”这两块。下面我按操作维度一一拆解。2.1 创建和初始化创建列表有三种常见方式# 方式一字面量 lst [1, 2, 3] # 方式二list() 构造 lst list(range(10)) # 方式三列表推导式 lst [x * x for x in range(10)]创建元组同样有三种对应方式# 方式一字面量 tup (1, 2, 3) # 方式二tuple() 构造 tup tuple(range(10)) # 方式三生成器表达式 tup tuple(x * x for x in range(10))这里有一个非常经典的陷阱创建只含一个元素的元组时括号会被当作优先级符号导致你以为创建了元组实际上只是生成了一个数字tup (1) print(type(tup)) # class int正确写法必须加一个逗号tup (1,) print(type(tup)) # class tuple其实不仅仅是单元素任何元组字面量的最尾部那个逗号都可以省略唯独单元素元组这个逗号是必须的。很多人写代码时会忘记等到后面拿这个“元组”去遍历的时候才发现报错TypeError: int object is not iterable。2.2 修改、删除与切片列表的增删改操作我不多讲大家一般都比较熟悉append、insert、pop、remove、del、extend加上下标赋值。这些都是元组完全不具备的。元组一旦创建你试图执行任何修改操作都会直接AttributeError。tup (1, 2, 3) tup.append(4) # AttributeError: tuple object has no attribute append tup[0] 99 # TypeError: tuple object does not support item assignment del tup[0] # TypeError: tuple object doesnt support item deletion但有一个很微妙的地方元组可以和另一个元组用拼接得到一个新的元组而原来的元组没有改变。a (1, 2) b (3, 4) c a b # (1, 2, 3, 4) # a 还是 (1, 2)b 还是 (3, 4)如果你把a b写出来效果是Python 先计算a b然后把结果赋给变量a。这个过程中创建了一个新元组变量a指向了新的对象原来的(1, 2)依然存在于内存中直到被垃圾回收。所以在循环里不断写tup (i,)本质上是在反复创建新对象效率很低这时不如用列表。切片方面列表和元组都能切语法完全一样lst [0, 1, 2, 3, 4] tup (0, 1, 2, 3, 4) print(lst[1:3]) # [1, 2]切片结果是列表 print(tup[1:3]) # (1, 2)切片结果是元组注意切片返回的类型和原类型一致这就是很多面试题里会出的点对字符串切片返回字符串、对列表切片返回列表、对元组切片返回元组。切片不会改变原数据不管是列表还是元组都是新的对象。2.3 遍历与解包遍历这块列表和元组的体验几乎一样都可以用for item in seq直接迭代也都可以用enumerate取索引和值。区别主要体现在“修改遍历中的元素”这个环节。你无法在遍历元组时修改元素但可以安全地读取。而遍历列表时如果想修改元素推荐用索引遍历或者enumeratelst [1, 2, 3, 4] for i in range(len(lst)): lst[i] lst[i] * 2 # 结果是 [2, 4, 6, 8]或者for i, val in enumerate(lst): lst[i] val * 2同时在遍历列表时尽量不要在循环体内删除当前迭代的元素这会导致索引错位。比如lst [1, 2, 3, 4, 5] for i in lst: if i % 2 0: lst.remove(i)这个代码的结果不是你预期的[1, 3, 5]而是[1, 3, 4, 5]。因为remove在遍历过程中会改变列表的长度和内部索引导致跳过某些元素。正确做法是先拷贝一份再遍历或者直接用列表推导式。解包是这两者最有魅力的地方尤其是元组解包。Python 支持带星号的占位解包first, *rest (1, 2, 3, 4) # first 1, rest [2, 3, 4]注意这个星号表达式的结果永远是一个列表即使是从元组解包。这个细节在写代码时很实用比如只取头尾元素head, *middle, tail [1, 2, 3, 4, 5] # head 1, middle [2, 3, 4], tail 52.4 嵌套与多维结构列表和元组都可以嵌套形成二维、三维乃至更高维的结构。嵌套列表典型比如二维矩阵matrix [ [1, 2, 3], [4, 5, 6], [7, 8, 9] ]嵌套元组典型比如多组坐标coords ((1, 2), (3, 4), (5, 6))还有一种非常常见的混合嵌套列表里的元素是元组比如students [(张三, 18), (李四, 19)]或者元组里的元素是列表比如config ([a, b], [c, d])。这里面有个很有意思的点元组里的元素是列表时元组不能增删元素但元组里那个列表的内容可以变。你必须明确认识到“元组的不可变”不等于“元组的所有东西都不可变”。我用一个具体例子tup ([1, 2], [3, 4]) tup[0].append(5) print(tup) # ([1, 2, 5], [3, 4])这个操作没有违反元组的不可变性因为tup[0]这个槽位仍然指向同一个列表对象只是那个列表的内部多了一个元素。重点在于tup[0] ...这种重新赋值的操作是被禁止的但tup[0].append(...)这种对内部可变对象进行修改的操作是被允许的。3. 什么时候该用哪个场景实战这是整篇文章的重头戏。理解了概念之后最重要的就是决策写代码时到底该用列表还是元组3.1 元组的典型应用场景第一用于表示固定结构、固定含义的一组数据。比如坐标点(x, y)一个三维颜色值(r, g, b)一个时间段(start_time, end_time)。这类数据有几个特点字段数量固定、字段顺序有意义、一般不需要在中间插入或删除。用元组的语义非常清晰读代码的人一看就知道这是“一个整体”而不是“一组同类数据”。第二函数的多返回值。Python 函数返回多个值时底层返回的其实就是一个元组def get_user(): name 张三 age 18 return name, age这里return name, age省略了括号等价于return (name, age)。调用方直接name, age get_user()完成解包。这种模式在 Python 里太常见了很多标准库函数就是这么设计的。第三字典的键或集合的元素。这是元组不可变性的红利。如果你想把两个值合成一个键比如坐标点(x, y)作为 key元组是唯一可行方案不考虑自定义类的情况下。第四可哈希的数据结构需要。当你需要把一组数据放入set中做去重且这组数据是固定结构时元组是天然的好选择。比如seen set() seen.add((1, 2)) seen.add((1, 2)) print(len(seen)) # 1如果试图把列表[1, 2]放进 set同样直接报TypeError。第五性能敏感的场景。这个我后面单独用一整段讲这里先给结论在数据量非常大的情况下元组比列表更快、更省内存。第六对数据安全有要求的场景。比如你的配置项不希望被某个函数意外修改用元组可以防止误操作。这种场景在团队协作里尤其有价值因为你没法控制其他同事写的代码会不会顺手把你的列表给append一下。3.2 列表的典型应用场景第一元素数量动态变化的场景。比如一个不断收集用户输入的数据集你无法预知会有多少条只能用列表不断 append。第二需要频繁插入、删除、排序、倒序操作的场景。这些操作列表都有内置方法支持元组完全无能为力。第三元素类型相同的批量数据。比如一个班级所有学生的成绩、一篇文章的所有行、一个文件夹里所有文件名。这类数据长度不固定含义一致天然适合列表。第四作为栈或队列使用。Python 的list.append()和list.pop()配合可以很方便地实现栈。用collections.deque可以实现双端队列但底层还是可变序列的逻辑。第五需要频繁遍历并且修改元素内容的场景。比如批量对数据进行清洗、转换、格式化列表直接下标赋值就能完成。3.3 一个实用的决策标准我在实际写代码时有一个习惯也是给新手的一条建议先问自己三个问题——这个数据集合的内容会不会变如果永远不变选元组。如果会变选列表。这个数据集合的长度是固定的吗如果长度固定且每个位置的语义不同选元组。如果长度不固定或者长度可能会变化选列表。这个数据集合需要作为字典的键或放进集合中吗如果需要只能选元组。如果不需要两者都可以再根据前两个问题判断。这三个问题基本能覆盖绝大多数场景。原则很简单能用元组的地方优先用元组因为不可变性是天然的“防呆设计”能减少意外修改带来的 bug。但如果你觉得别扭不想动脑判断那就用列表Python 社区里默认用列表的人数绝对占多数代码也更容易被人理解。话说回来不要为了炫技而刻意用元组。如果一个数据你最终要排序、要修改、要动态增删用元组只会让你的代码又臭又长——每次修改都重新创建一个新元组不如列表来得直接。4. 性能与内存元组到底快在哪很多性能对比的文章都会告诉你“元组比列表快”但不会告诉你具体快在哪里、快了百分之多少、什么情况下快。这里我用三个方向实测说明并且给出判断依据。4.1 CPU 访问时间的对比在 CPython 中元组和列表的底层结构很相似都是变长的对象数组每个元素都是一个PyObject*指针。访问元素时逻辑几乎一样都是根据索引偏移去取指针。但有一个微小差异列表在迭代时需要进行“列表是否被修改”的检查因为如果你在迭代列表的过程中删除了元素迭代器需要感知到这个变化并抛出RuntimeError。这个检查在每次迭代时都会触发而元组不可变编译器可以省掉这个检查。这导致在大规模循环中元组的迭代会快一点点。实际测试中这个差异一般来说在数千万级别的循环里才能显现出肉眼可见的差距普通业务代码里几乎感受不到。所以如果你选元组是为了“更快地遍历几万个元素”说实话收益很低反而是“避免误修改”这个收益更实在。4.2 内存占用的对比这一块差异更明显。列表在创建后如果不断 append 元素Python 会预分配多余的内存空间以便后续扩展时不至于频繁重新分配内存。这就是列表内存被“预分配”掉了你的 5 个元素可能占了 8 个元素的内存空间。元组没有这个机制。元组在创建时就精确分配了容纳指定数量元素的内存不多不少。这个差异在元素数量很大或者元组数量很多时非常明显。我写过一个简单测试import sys lst list(range(1000)) tup tuple(range(1000)) print(sys.getsizeof(lst)) # 通常 8440 左右 print(sys.getsizeof(tup)) # 通常 8040 左右差距不算离谱但当一个程序里有上百万个元组/列表对象时差额就会急剧放大。数据越多元组省下的内存越可观。另外Python 对元组还有一个缓存机制当某个元组不再被使用、被垃圾回收后如果它的大小不是特别大CPython 会把这个元组的内存块缓存起来供后续创建相同大小的元组直接复用。这个机制让元组的创建和销毁在某些场景下比列表更快。4.3 为什么元组能作为字典键而列表不能性能上的考量我们前面说了列表无法哈希无法作为字典键。这个规则的底层逻辑仍然和“可变性”强相关。如果允许可变对象作为字典键那么当你往列表里加了一个元素后它的哈希值就应该变化但字典在插入时已经把它放在了某个哈希桶里。下次查找时哈希值变了会到完全不同的桶里找结果找不到或者找到错误的值。这会导致字典的数据结构彻底崩溃。元组不存在这个问题。因为它的内容不会变哈希值一旦确定就永远稳定。这也意味着元组的哈希值是可以在创建时就计算好的Python 内部会缓存这个结果后续访问hash(tup)时直接返回缓存耗时几乎为零。tup (1, 2, 3) print(hash(tup)) # 立刻返回无需重算这对大量使用元组作为键的程序来说是一个隐性的性能优势。5. 常见误区和避坑经验汇总从我自己带新人的经验看列表和元组这个知识点看起来简单坑却不少。下面汇总几个特别典型的误区每一个我都见过真实翻车案例。5.1 误区一元组里的列表不会被修改前面已经反复强调过了元组是不可变的但元组内元素的内部内容不受此限制。你写config ([admin, user], [read, write]) config[0].append(guest) # 这个操作是允许的很多刚学 Python 的人以为元组一锁全锁实际上元组只锁“引用”不锁“引用所指的内容”。如果真要彻底不可变需要自定义不可变容器或者使用嵌套的深层元组。这个特性在安全相关的代码里也是一个需要警惕的点元组并不能保护你的数据不被内部对象修改。如果你的数据安全完全依赖元组可能会被钻空子。5.2 误区二单元素元组忘写逗号这个坑真的几乎人人都踩过。千万记住(1)不是元组(1,)才是。更隐蔽的是元组打印出来时逗号总是存在但创建时很容易漏掉。我见过一个实际案例某同事从配置文件解析了一个单元素列表转元组后拿去当数据库查询参数一直报int too many arguments。排查了半小时最后发现是tuple([1])得到的是(1,)而不是他以为的(1)。一开始他以为tuple([1])会失败结果没有失败只是后面参数展开时出了问题。5.3 误区三对循环中元素的修改“没有生效”看这段代码lst [[1, 2], [3, 4]] for item in lst: item [9, 9] print(lst) # 仍然是 [[1, 2], [3, 4]]因为 item [9, 9] 只是改变了局部变量指向这在列表和元组中都会遇到因为item ...是给局部变量重新绑定一个新对象没有影响原序列。但下面这个写法是生效的lst [[1, 2], [3, 4]] for item in lst: item.append(9) print(lst) # [[1, 2, 9], [3, 4, 9]]这会直接修改原列表中的嵌套列表。这两者的区别本质上是“重新绑定引用”和“修改引用所指对象的内容”的区别。初学者一定要在头脑里建立这个模型否则很多莫名其妙的问题都找不到根源。5.4 误区四在循环里用元组做累积拼接有同事写过去重逻辑思路是遍历一批数据符合条件的元素不断tup (item,)加到元组里。数据量少的时候还行数据量大就开始卡顿因为每次都会创建一个全新的元组对象旧的元组被丢弃反复分配内存和复制数据复杂度接近 O(n²)。正确的做法是用列表收集最后如果需要元组再tuple(lst)一次性转换。result_lst [] for item in source_data: if condition(item): result_lst.append(item) result_tup tuple(result_lst)5.5 误区五混淆与is元组和列表在比较时有一点容易被忽视比较的是内容是否相等is比较的是是否是同一个对象。a [1, 2, 3] b [1, 2, 3] print(a b) # True print(a is b) # False对于相同内容的两个不同列表是 True。元组也一样两个内容相同的元组也是 True。但这里有一个 Python 解释器的优化细节对于不可变的小整数、短字符串和某些简单的元组CPython 可能做了 intern 或者常量折叠导致a is b可能返回 True。这个行为依赖于具体值的长度和解释器版本不能当作可靠逻辑。x (1, 2) y (1, 2) print(x is y) # 某些环境下可能 True但不要依赖这种“随机性”如果被写进业务代码会造成非常隐蔽的逻辑 bug。5.6 误区六从 Excel 或数据库读取数据后疯狂改列结构很多做数据处理的人会遇到这种情况从数据库读取一行数据默认拿到的是一个元组。这时如果你直接试图追加一列、修改某个字段就会报错。新手会觉得元组太不灵活转而把所有行都转成列表。我的建议是不要一上来就转列表。先确认原始数据是否需要修改。如果只是读取、传递给其他函数、作为不可变记录持久化元组就非常好还能顺便防止下游代码不小心改动数据。如果确实需要增删列再list(row)转成列表或者用 NamedTuple 这种更高级的数据结构。5.7 简单速查表我整理这张表方便日常写代码时对照对比维度列表 list元组 tuple创建语法[1, 2, 3](1, 2, 3)可变性可变不可变可哈希否是元素也必须可哈希用作字典键不行可以动态增删元素支持不支持排序、反转可以原地操作只能新建排序结果内存占用高有预分配低精确分配迭代速度稍慢稍快实际差距微弱典型用途动态数据集、批量操作固定结构、函数多返回值、字典键6. 一些进阶的实战小技巧这里说几个我自己在工作中经常用到的操作都是列表和元组搭配才能发挥最大威力的写法。6.1 用*来拆解可迭代对象不仅函数参数可以用*args普通赋值和解包也能用*。比如你要把一个列表和一个元组合并lst [1, 2] tup (3, 4) combined [*lst, *tup] # [1, 2, 3, 4]这个写法的好处是直观、不用写循环而且在处理混合结构时特别优雅。6.2 将列表转换成元组实现参数保护你写了一个函数接收用户传入的配置项但你不希望函数内部意外修改用户的原始数据列表。可以在函数入口加一行config tuple(config)。def process_config(config): config tuple(config) # 保护后续代码不会意外修改 # 后续逻辑这是一种非常轻量级的防御性编程手法。6.3 使用collections.namedtuple如果你喜欢元组的不可变性又希望字段能直接通过名字访问可以升级到namedtuple或者dataclass。from collections import namedtuple Point namedtuple(Point, [x, y]) p Point(3, 4) print(p.x) # 3 print(p.y) # 4 p.x 5 # AttributeError不可修改namedtuple在返回值语义清晰、又不需要写一个完整类的时候特别好用。特别是代码在多个函数之间传递一组固定字段的数据时比直接返回元组然后靠位置猜字段要安全得多。6.4 注意可变默认参数陷阱这虽然不是列表和元组直接对比的问题但非常常见在函数定义中使用可变对象作为默认参数会导致所有调用共享同一个对象。def add_item(item, lst[]): lst.append(item) return lst多次调用会累积因为默认列表在函数定义时就被创建了每次调用都往同一份列表里追加。如果用元组默认值就不会有这个问题因为元组不可变。这个例子反过来印证了元组的不可变性在 API 设计中的价值。7. 最后的建议列表和元组的选择没有绝对的对错但有“更合适”和“更别扭”的区别。原则就是能确定不变的数据优先用元组需要动态操作的数据别犹豫用列表。我从实际项目里得到的体会是元组的不可变性质不光是语法层面的限制更是一种代码意图的表达——你用元组就是在告诉读代码的人“这段数据是一组具有固定结构的信息我不希望任何人随意改动它”。这种表达能力在团队协作中尤其珍贵减少了大量因为“不知道哪个函数偷偷改了数据”而耗费的排查时间。初期如果你拿不准就记住一句话改不动的东西用元组改得动的东西用列表。把这两种基础数据结构用顺了后面学字典、集合、列表推导式都会顺畅很多。
返回列表