ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Python列表与元组:可变性差异、性能对比与选型指南

2026/9/8 3:28:02 拓冰建站 浏览量
Python列表与元组:可变性差异、性能对比与选型指南 先来说一个我自己带新人时经常遇到的现象很多人学Python学到列表和元组感觉这俩长得几乎一模一样都能存一串数据都能用下标访问甚至for循环遍历起来的写法都一样。于是写代码的时候就随便挑一个用等到代码跑起来才发现要么是性能不对劲要么是设计上被坑了一把。为什么这两兄弟看似相同但在真实项目里的表现差别这么大核心就是一句话列表是可变的元组是不可变的。听起来很简单但这一条差异背后牵扯出来的内存模型、使用语义、性能特征、甚至是并发安全问题足够写一篇很长的文章了。这篇内容就是想帮大家把列表和元组彻底搞清楚包括它们的底层差异、性能对比、易踩的坑以及实际开发中怎么选型。不管你是刚入门Python的初学者还是已经写了几年Python但一直靠“感觉”来选类型的开发者这篇文章应该都能给你一些新的角度。1. 先看本质列表和元组到底差在哪1.1 一个变量赋值背后发生了什么事要理解列表和元组的区别我们要先放下“能不能增删元素”这个表层认知去看它们在被创建和操作时Python解释器到底做了什么。我们用一段最简单的代码来感受一下a [1, 2, 3] b a b.append(4) print(a) # [1, 2, 3, 4] c (1, 2, 3) d c # d 没有任何办法往元组里再加一个元素列表可以append元组不行。这是几乎所有教程都会讲的第一课但很多人只记住了这个结论没有继续往下想为什么列表可以被修改为什么元组不行从内存角度理解就清楚了。列表在底层是一段动态连续内存里面存储的是指向各个元素的指针。当你执行append的时候Python解释器会检查当前这块内存是否还有剩余空间如果有就直接在末尾写入一个新指针如果没有就重新申请一块更大的内存把旧数据拷贝过去再插入新元素。这就是“动态数组”的经典实现。它的本质决定了列表的容量是可变的元素数量是动态的。元组呢元组在底层也是一段连续内存里面同样存指针但这段内存在创建时大小就固定了解释器不会为它提供任何扩容机制。从此以后这块内存里每个位置放什么就是定死的。你无法往里面塞新元素也无法把某个位置重新指向另一个对象。这个“官方的不给改”的设计带来的影响比大多数人想得要深远得多。我们接着往下看。1.2 内存占用与缓存机制为什么元组更“轻”既然元组的大小固定它的内存占用就非常可控。我用Python做了一个简单实测你直接看结果import sys list_1 [1, 2, 3, 4, 5] tuple_1 (1, 2, 3, 4, 5) print(sys.getsizeof(list_1)) # 通常为 104 print(sys.getsizeof(tuple_1)) # 通常为 80同样的5个元素列表比元组多占了24字节。这还只是5个元素当元素量大起来这个差距会更明显。原因在于列表为了支持动态扩容会预先多分配一些内存避免每次append都触发内存重分配。Python源码里有一个list_resize的机制扩容时会按照一定的增长策略大约是newsize (newsize 3) 6多申请一些空间。所以哪怕你只是创建一个5个元素的列表它也可能已经预留了8个甚至更多元素的空间。元组没有这个问题因为它根本不需要“预分配”创建时用多少就分配多少。除了内存占用元组还有一个隐藏的优化——缓存复用。Python解释器会缓存一定数量没有被引用的元组对象尤其是空元组和只含少量元素的元组后续创建同样大小的元组时可能直接复用内存省掉了重新分配的开销。列表没有这种缓存机制每次创建都是实打实的新内存分配。这也是为什么在很多性能测试里元组的创建速度明显快于列表。注意这里的“缓存”是解释器层面的优化我们写业务代码时不需要刻意利用它但它们能解释为什么内部代码和标准库里大量使用元组——性能好内存省。1.3 不可变性带来的三个隐藏优势很多人把“不可变”只理解成“不能改”然后就完事了。但“不能改”这三个字在真实的软件工程里能延伸出很多价值我挑三个最关键的讲。第一个是可哈希。Python里只有不可变对象才能作为字典的键或者放进集合set里。元组满足了__hash__的条件所以你可以写{(1, 2): point}但不能写{[1, 2]: point}一写就会报TypeError: unhashable type: list。这个特性在做坐标缓存、把一组固定配置当键存储的时候非常好用。第二个是安全共享。元组可以被安全地到处传递不用担心某个函数悄悄改了里面的内容。比如我们定义了一组默认配置用元组存起来传给下游的多个函数每个函数拿到它之后只能读不能改这在多人协作、大型项目里能减少大量隐性问题。列表就危险得多一个不守规矩的函数对它执行了append或remove调用的上层可能莫名其妙就发现配置变了。第三个是语义表达。元组天然适合表达“一组固定顺序、固定数量的数据”。比如一个二维坐标(x, y)一个RGB颜色(255, 255, 255)一个用户记录的姓名和年龄(张三, 20)。这些东西的数量和含义在创建时就已经定死了将来不该被增删用元组就是恰如其分用列表反而容易误导人让人以为可以随意追加数据。2. 列表和元组的具体差异不止“能不能改”这么简单2.1 内置方法对比列表的“工具箱”更丰富如果你打开两个类的内置方法列表会发现列表的方法明显比元组多。列表有append、extend、insert、remove、pop、clear、sort、reverse、copy这一大堆元组却只有两个常规方法count和index。count用来统计某个元素出现的次数index用来查找某个元素的下标。这不是元组能力弱而是设计上的刻意取舍。由于不可变元组根本不需要也不需要提供任何修改自身的方法。它的定位不是“用来增删改查的容器”而是“用来安全保存固定数据的结构”。我见过一些刚入门的同学想用元组存人名然后试图调用append加一个新名字进来报错之后一头雾水。这其实就是没把两者的定位搞清楚。列表负责增长和变化元组负责固定和稳定。关于index方法这里提一个容易忽略的细节元组的index和列表一样可以指定查找的区间比如t.index(3, 1, 4)表示在下标1到3之间找3这个值的第一次出现位置。如果你要在一大段数据里频繁查找下标用index的效率是O(n)数据量大时不如改用字典哈希表来维护索引。2.2 一个字母之差单元素元组的大坑这是几乎所有Python教程都会强调、但还是年年有人踩的坑创建一个只有一个元素的元组必须带上逗号。a (1) # 这是整数1不是元组 b (1,) # 这才是元组 print(type(a)) # class int print(type(b)) # class tuple为什么因为括号在Python里除了用来写元组还有一个重要功能就是改变运算优先级。(1)在这里被解释器当成一个普通的数学表达式结果就是整数1。写成(1,)之后逗号让解释器明确这不再是普通括号而是一个元组的字面量。这个坑在函数传参的时候尤其隐蔽。比如你写update_config((host,))如果你少了个逗号传进去的就成了一个字符串或什么别的类型后面的逻辑全乱了。还有用元组拼接的时候(a,) (b,)是对的(a) (b,)会直接报类型错误。批量化生产的项目里这种问题一旦出现定位起来通常要花不少时间。提示不仅是单元素元组任何你想表示“一个元素的元组”的场景都别忘了逗号。这是语法层面强制要求没有商量的余地。2.3 元组里的可变对象一个“不可变”的悖论很多人学到后面会碰到一个困惑的问题元组不是不可变的吗为什么下面这段代码能成功t ([1, 2], 3) t[0].append(4) print(t) # ([1, 2, 4], 3)看起来变了但实际上没变。要理解这个必须分清“元组本身不可变”和“元组里的元素可不可变”是两回事。元组的不可变性指的是元组里的每个槽位指向哪个对象这个“指向关系”是固定的。t[0]从头到尾都指向列表[1, 2]这个指向关系没有变。但是列表本身是可变对象列表里的内容发生了变化从[1, 2]变成了[1, 2, 4]——这是列表自己的事管不到元组头上。可以这样类比元组是“一把座位固定的椅子”椅子上的每个座位都钉死了一个人。但如果固定的人在座位上换衣服、做动作那是这个人的自由椅子管不着。元组的“不可变”约束的是座位的位置不是座位上那个人的内部状态。所以如果你用元组去存一个普通的数字、字符串这些不可变对象那么“完全不可变”这个描述是准确的但如果你把列表、字典、自定义对象这些可变对象放进元组就要小心元组能保证的只是你那几个元素的引用不会变元素内部该变还是会变。2.4 性能实测创建、访问与遍历的差异既然谈到区别性能差异是绕不开的。我写了一段简单的测试脚本测了创建、索引访问和遍历操作的耗时import timeit # 创建性能 print(timeit.timeit([1, 2, 3, 4, 5])) # 约 0.04 微秒量级 print(timeit.timeit((1, 2, 3, 4, 5))) # 约 0.02 微秒量级 # 索引访问 print(timeit.timeit(a[2], a[1,2,3,4,5])) # 基本持平 print(timeit.timeit(a[2], a(1,2,3,4,5))) # 基本持平 # 遍历 print(timeit.timeit(for x in a, a[1,2,3,4,5], number10000)) print(timeit.timeit(for x in a, a(1,2,3,4,5), number10000))从测试结果来看创建速度元组明显更快索引访问和遍历因为两者底层都是连续的线性结构访问方式几乎一样所以差距并不大。元素量越大元组的创建优势越明显。但如果你的业务大量集中在已经创建好的列表/元组上进行遍历和索引访问选哪个对你来说性能上几乎无感知。真正的性能差别在哪一是在高频创建小对象的场景比如循环里反复构造数据二是在存储量很大的场景元组内存更省。搞清楚自己的瓶颈在哪再决定用哪个不要盲目迷信“元组就是比列表快”这种笼统的说法。3. 实际项目里的选型什么场景用列表什么场景用元组3.1 列表的典型使用场景数据收集与动态处理注意以下算是我在项目中最常见的判断标准——如果你要在运行过程中不断往容器里加东西、删东西、改顺序那就用列表别犹豫。举几个例子你就懂了。一个是数据收集。比如你从接口里一页页翻数据每翻一页就往结果里追加一批记录最后统一处理。这种场景数据量不确定数量一直变天然就是list的活儿。你用元组根本没法做增量。一个是队列和栈。Python的标准库collections.deque虽然是更专业的队列选择但在很多简单场景里直接用列表模拟栈append入栈pop出栈或者当临时队列用非常方便代码也直观。元组不支持这些操作自然不在考虑范围。还有一个是需要排序和反转的场景。列表自带sort()和reverse()元组没有。虽然你可以用sorted(tuple)拿到一个新列表但如果你要的是“原地排序、原地反转”元组做不到必须转成列表。我这里有个原则当数据集合的规模、顺序、内容在未来有变更可能性时默认用列表只有当你能百分之百确定它不会变时才考虑元组。3.2 元组的典型使用场景固定结构与安全传递元组也有几个自己的高光场景很多高手写代码时会把它们用得很顺手我挑四个最常遇见的。第一个是函数的多返回值。Python函数返回多个值时本质上就是打包成一个元组def get_user(): return 张三, 20, 北京 name, age, city get_user()注意return 张三, 20, 北京这个写法返回的其实就是一个元组(张三, 20, 北京)。这个场景下元组是语法上最自然的选择你不可能让函数返回一个列表然后让调用方去append吧完全没必要。第二个是作为字典的键。上面提过元组可哈希列表不可哈希。当你需要用一组固定值作为键来存数据时元组是唯一的简单选择。比如用坐标(x, y)作为键存二维地图上的信息用(user_id, course_id)作为键存选课关系都是非常经典的做法。第三个是***args可变参数**。在Python里定义函数时用*args收集的多个位置参数到了函数内部就是以元组形式存在的。这是因为调用方传进来的参数数量在定义时不可预知但一旦接收下来这批参数就是“已经定好了的一组数据”。让它们是元组而不是列表也是安全考虑——免得函数内部不小心改了调用方的参数。第四个是格式化字符串。%s is %d years old % (Tom, 18)这个老式格式化写法右侧本质上就是个元组。还有print(%s:%s % (host, port))这种类似模式。虽然现在f-string是主流但你在维护老代码时一定会碰到这种写法知道右侧是元组这件事出问题时才好排查。3.3 介于两者之间namedtuple和dataclass的选择有些场景有点尴尬既要“元组的固定不可变”又希望“字段有名字代码更易读”。这时候标准库里的namedtuple具名元组是一个极具性价比的方案。from collections import namedtuple Point namedtuple(Point, [x, y]) p Point(3, 5) print(p.x) # 3 print(p.y) # 5namedtuple底层仍然是元组所以它保留了不可变、可哈希的特性可以用作字典键同时它又给每个位置起了名字访问起来比(p[0], p[1])更直观代码的可读性直接拉满。在解析一行CSV、一条API返回值、一个坐标等“有固定字段的轻量级数据”时namedtuple非常好用。如果项目用的是Python 3.7还有一个更现代的选择——dataclass。dataclass默认是可变的但你可以加frozenTrue让它变成不可变from dataclasses import dataclass dataclass(frozenTrue) class Point: x: int y: intfrozenTrue的dataclass和类实例化的方式很像它比namedtuple更能承载复杂的类型注解、默认值、方法定义等。当一个“固定结构”逐渐变复杂要加方法、要加校验、要加继承时我会倾向于用dataclass(frozenTrue)替代裸元组。我的个人建议是极简固定数据用元组字段有业务含义但结构很轻用namedtuple字段多、逻辑复杂就用frozen dataclass。这个选型不是绝对的但它能帮你把代码的表达力和性能之间平衡得比较好。4. 实操细节与常见陷阱从切片到解包再到内存分析4.1 切片操作列表切片是“浅拷贝”不是“引用”列表切片这个操作热度一直很高新手老手都会用但有个细节特别容易翻车a [1, 2, 3, 4, 5] b a[1:3] # [2, 3] b.append(99) print(a) # [1, 2, 3, 4, 5]a 不受影响看到没有b是a的一个新列表修改b不会影响a。但如果你把它当成“只是看一眼原列表的数据”然后直接对b疯狂操作以为自己改的是原列表结果原列表纹丝不动这就容易困惑了。反过来如果你想要的是“原列表的一个视图”改视图等于改原列表那你要做的不是切片而是直接赋值b a新变量指向同一块内存或者使用array等支持buffer协议的数据结构。列表切片永远是浅拷贝这个结论要记牢固。如果列表里嵌套了可变对象浅拷贝的另一个坑就出来了a [[1, 2], [3, 4]] b a[:] # 浅拷贝 b[0].append(99) # 改的是内层列表 print(a) # [[1, 2, 99], [3, 4]]外层的a和b是两个独立列表但你改b[0]指向的那个内层列表时a的内层列表也被改了因为浅拷贝只复制外层内层列表还是同一个对象。要彻底独立得用深拷贝copy.deepcopy(a)。4.2 解包与星号操作符让元组和列表“拆”得优雅解包unpacking是Python里非常优雅的语法既适用于列表也适用于元组。最基础的用法如下coords (100, 200) x, y coords items [1, 2, 3] first, second, third itemsPython 3还支持星号表达式可以方便地把多余元素收集到一个列表中first, *rest, last [1, 2, 3, 4, 5] print(first) # 1 print(rest) # [2, 3, 4] print(last) # 5这个写法在处理“首尾元素中间部分”这类逻辑时特别爽。比如你在处理一组日志要取第一条、最后一条、中间的所有条一个解包就搞定了不需要再去算下标。用元组交换两个变量的值也是Python里让人“哇”一下的经典a, b b, a它的底层就是先把(b, a)构造成元组再解包赋值回a, b。一句话就完成了别的语言要三步才能做的事。4.3 用sys.getsizeof和id分析内存与身份如果想要真正“看见”列表和元组的内存差异sys.getsizeof和内置函数id是两个好工具。sys.getsizeof能返回一个对象占用的字节数。我曾经用它在一次性能优化里定位问题一个数据处理服务要从DB里读出大量记录拼成临时结构我一开始用列表存每一条记录的所有字段结果内存直接吃满。后来发现每条记录都是定长字段、不需要增删改成元组之后内存占用下降了接近30%GC压力也明显小了。id能返回一个对象的唯一身份标识也就是它在CPython里的内存地址。这个函数可以用来验证“修改到底发生在谁身上”a [1, 2, 3] b a print(id(a) id(b)) # True同一个对象 c a[:] print(id(a) id(c)) # False切片创建了新对象我在调试另一个“多个变量互相影响”的问题时就是用id一个一个比对发现某处不小心把一个列表对象赋值给了两个属性导致一个模块改数据另一个模块全看到了。使用id定位这种引用共享问题比肉眼盯着代码猜要高效得多。4.4 常见问题排查速查表结合我日常带新人和自己踩过的坑整理几个高频问题你收藏起来可以直接查。问题原因解决方案TypeError: tuple object does not support item assignment尝试给元组元素赋值确认数据结构是否该用列表如果是故意要改转成列表再操作TypeError: unhashable type: list把列表当成字典的key或放入set列表不可哈希改用元组作为key单元素元组被当成普通括号表达式忘记在单元素后加逗号创建单元素元组时一定要写(x,)元组里有嵌套列表/字典内容在外部被“意外”修改混淆了“元组不可变”和“元素对象可变”如果要求深度不可变避免放入可变对象或用frozen dataclass并深拷贝使用a[:]修改原来的列表以为会改动原变量切片返回的是新列表想操作原列表直接用a或使用a[:] ...整体替换函数返回多个值调用方忘了按顺序接收多返回值本质是元组使用解包或namedtuple提高可读性大量append之后内存占用暴涨列表动态扩容会预留额外空间可以使用array模块或在确定大小后预分配列表长度创建大量小对象的循环性能太慢列表创建成本比元组高如果是固定结构循环内改创建元组4.5 深入一点了解列表扩容机制才能解释“为什么别乱预留”上面提到列表会为了后续append预留内存具体逻辑我们可以简单展开。CPython中list_append的内部逻辑会先判断已分配的内存容量是否足够不够就调用list_resize扩容。这个扩容策略以“每次扩容大约增加到原来的1.125倍加上一个增量”来进行而不是每次只加1个槽位。这样做是为了摊薄频繁append时的拷贝开销。但这也带来了一个trade-off如果你的业务是“先创建列表之后只读不增删”那么列表里那部分预留容量就是纯浪费。我见过有同事提前用[None] * 10000生成一个超长列表最后只填了前500个位置剩下9500个空位就一直占着内存。这种情况下如果数据结构固定用元组或直接用小列表更合理如果确实需要“预分配”至少要清楚自己预分配的量大概是多少千万别拍脑袋写一个特别大的数。提示如果你确实需要一个只读列表并且不想承担列表预留内存的开销最优雅的写法是tuple([1, 2, 3])转成元组再用。这样既能用列表推导式方便地生成数据又能享受元组的轻量内存和不可变保护。5. 从语法层面的差异说到代码风格和团队协作列表和元组的选择其实不只是性能或功能问题它还会影响代码的可读性和团队协作的默契。我在Review代码的时候看到函数参数声明成def calc(data: list)和def calc(data: tuple)对函数内部行为的预期是完全不同的。前者我默认函数内部可能会修改这个列表后者我默认它只读不写。这种“类型即文档”的属性在多人协作的项目里尤其有价值能显著减少沟通成本和误用概率。给一个简单可落地的团队规范参考命令行参数、接口返回的固定字段序列用tuple或namedtuple表达“不可变”。业务处理过程中累计增量数据的集合用list表达“可增长”。坐标、RGB值、数据库里一行记录等纯数据载体优先用tuple/namedtuple。如果某个数据结构在需求分析阶段就认定“未来极可能加字段”直接上dataclass(frozenTrue)比后期从tuple迁移强得多。还有一点容易被忽略但很实际用元组存数据时解包赋值的可读性需要自己维护。比如一个元组里有5个字段你写a, b, c, d, e record读代码的人必须靠变量名去猜每个位置的含义。这种情况我更推荐namedtuple它能给每个字段一个名字真正把“语义”焊在代码里。我自己的经验是把列表和元组的选择纳入代码规范比在代码里写大量注释更“防呆”类型本身就传递了意图省去了一大堆“这个列表不要修改”“这个数据是固定的”之类的注释而且类型检查工具比如mypy也能帮你提前发现错误。6. 一个完整案例统计文章高频单词该用列表还是元组为了把前面的知识点串起来我写了一个非常典型的小案例来收尾。假设我们拿到一篇文章的单词列表需要统计每个单词出现的次数然后输出前5个高频单词及其出现次数。第一版代码可能长这样from collections import Counter words [the, python, is, the, best, python, is, powerful] counter Counter(words) top5 counter.most_common(5) print(top5) # [(the, 2), (python, 2), (is, 2), (best, 1), (powerful, 1)]看到没有most_common(5)返回的是一个列表里面每个元素是一个元组。这个表达非常自然外层列表是因为排名数量可能变化、我们需要动态地访问、切片、排序内层元组是因为“单词次数”这两个数据的结构固定而且我们完全可以只读不写。如果你想让内层修改比如给每个单词额外信息再加一个字段那就该换dict或dataclass。再看一个改动你发现Counter结果需要排序写法是sorted(counter.items())。counter.items()返回的密钥-值对序列本质上就是元组的集合。这里用元组而不是列表就是为了保证“键值对结构不被意外修改”。很多团队代码里大量把字典项转成元组再继续操作都是基于这个道理。这个案例很小但它说明了一个核心思想列表用于“数量可变、内容可管理”的外层组织元组用于“字段固定、组合语义明确”的内存数据载体。把这个思路用到实际架构中你的Python代码会更符合语言的表达习惯也更不容易出现低级错误。最后分享一个我自己写代码时的判断习惯每当我准备声明一个容器变量的时候我会心里问一句“这个数据在接下来的代码里有没有可能被增加、删除、重排”如果答案是会就用列表如果是不会我再看它有没有清晰的字段语义有就考虑namedtuple没有就用元组。这个习惯帮我避免了很多不必要的类型变更和重构。希望这篇文章也能帮你建立起自己的判断标准。