☰
Python列表与元组全解析:可变性、性能与选型指南
2026/9/28 15:00:27 网站建设 项目流程

列表和元组,Python 里最容易被“一眼带过”的两个内置容器。我见过太多人学了两三个月 Python,列表用得很溜,元组只当它是一个“不能改的列表”,问起来区别,答一句“可变和不可变”就算完。可真到项目里,该用元组的地方用列表,该用列表的地方纠结半天,一不留神还踩进可变默认参数、切片复制、多变量解包这些坑里。

这篇文章我就把这些事彻底讲透:列表和元组到底差在哪,为什么 Python 要同时提供这两种看起来差不多的东西,实际开发中怎么选、怎么用才不容易出错。不管你是刚入门的新手,还是已经写过一阵子 Python、想在细节上再补一补的开发者,这篇文章都会有用。

1. 列表和元组,到底差在哪?

1.1 可变与不可变:所有区别的根源

先看最基本的定义。列表是一个可变序列,创建后可以随时增删元素、修改某个位置的值;元组是一个不可变序列,创建后不能修改、不能增删。直接看代码最直观:

lst = [1, 2, 3] lst[0] = 100 lst.append(4) print(lst) # [100, 2, 3, 4] tup = (1, 2, 3) tup[0] = 100 # TypeError: 'tuple' object does not support item assignment tup.append(4) # AttributeError: 'tuple' object has no attribute 'append'

这两段报错大概是很多人第一次真正意识到“元组不能改”的时刻。但仅仅记住“能改 vs 不能改”远远不够,你得明白为什么 Python 要提供一个“不能改”的容器。想想看:如果你的代码里有多个变量、多个函数共享同一个数据,列表这种能随时修改的东西,很容易在某个不起眼的地方被改动,然后引发一串难以排查的副作用。而元组不可变,意味着它在创建之后的值是稳定、可信任的,无论传到哪个函数、被多少处代码引用,都不会悄悄发生变化。

我用一个生活类比来帮你记:列表像一张草稿纸,你可以随便写、随便改、随手撕掉重来;元组像一份已经打印好并盖了章的合同,条款定下来就是定下来了,想改只能重新出一份新合同,而不是在原件上涂改。这个“想改只能重新创建”的特性,就是不可变对象的本质。

1.2 可变性带来的连锁反应:内存、哈希、性能

可变和不可变不只是“能不能改”这么简单,它会带来一连串连锁反应。

首先看内存布局。列表为了支持动态增删,在底层会预留一部分额外空间,每次 append 或 insert 时,如果剩余容量不够,Python 会重新分配一块更大的内存,再把原有元素搬过去。元组不需要这些,它创建时一次性分配恰好能装下所有元素的内存,结构非常紧凑,没有额外开销。这也是为什么迭代同一个数据时,元组通常比列表稍微快一点,内存占用也更小。当然这种差异在元素少的时候几乎感觉不到,但当你需要创建成千上万个小型数据结构时,就能看出差距。

其次是复制行为。列表的copy()或切片[:]会生成一个新列表,但属于浅拷贝,新列表里的元素本身还是和原列表共享的。元组因为不可变,拷贝时 Python 很多时候直接返回同一个对象,反正改不了,多一个引用完全不影响。

然后是哈希能力,这条非常重要。Python 中只有不可变对象才能计算哈希值,所以元组可以作为字典的键、可以放进集合,列表不行:

d = {('a', 1): 'value'} # 合法 d = {[1, 2]: 'value'} # TypeError: unhashable type: 'list' s = {('x', 2)} # 合法

报错信息里的unhashable,说白了就是“这个对象没法哈希”。哈希的本质是把一个对象映射成一个固定整数,要求对象内容不变、哈希值稳定。列表内容可变,同一个列表今天[1, 2]明天[1, 2, 3],哈希值没法稳定,所以直接被排除在可哈希类型之外。

最后是整体性能差异。我做一个非常粗略的对比,你用sys.getsizeof可以自己验证:同样是装 5 个小整数的容器,列表要占 80 字节左右,元组只要 72 字节左右;构建速度上,元组也比列表快一些,因为少了很多扩容检查的逻辑。这些数字在不同 Python 版本里会有变化,但趋势是一致的。

2. 什么时候该用列表,什么时候该用元组?

2.1 从“数据性质”做第一判断

说实话,很多 Python 开发者的默认习惯就是“凡是集合一律用列表”,元组只在解包返回值时被用到。我不打算劝你把所有列表都改成元组,但确实有一类数据天生就适合元组,判断标准很简单:

  • 这个数据集合的长度是否固定?
  • 这个数据集合的内容在创建后是否需要保持稳定?
  • 这个数据是否要作为字典键或集合成员使用?

如果三个问题里有两个回答是“是”,你就该认真考虑用元组。

举几个最常见的例子。坐标点(x, y)、RGB 颜色(255, 128, 0)、经纬度(39.9, 116.4),这些数据的维度是固定不变的,放在元组里语义最清晰,还能防止别人不小心改掉其中一个值。数据库查询返回的一行记录,比如(id, name, created_at),结构固定,用元组也比用列表更合适。反过来,用户列表、日志列表、待办事项这类长度会动态变化、需要不断添加或删除的数据,列表就是不二之选。

有一个很微妙但很好用的经验:元组表达的是“这些数据放在一起是有结构的”,列表表达的是“这些数据是一组同类的可枚举项”。同样是三个字符串,('张三', '李四', '王五')让人感觉这三个名字是固定的一组标签;['张三', '李四', '王五']则更像一份可以继续追加名字的花名册。语义上的细微差别,其实也是 Python 设计者提供两种容器的原因之一。

2.2 从“运行场景”做第二判断

数据性质判断完之后,再看具体的使用场景。有几个高频场景是元组的“主场”:

第一个场景是函数返回多个值。Python 里return a, b本质上返回的是元组(a, b),接收时用x, y = func()拆包。这是元组最常见的用途,甚至很多新手根本意识不到自己每天都在用元组。

第二个场景是作为字典键或放进集合。当你要用一个组合信息作为键时,比如用(user_id, month)作为统计数据的键,只能选元组,因为列表不可哈希。这种情况下用元组不是可选项,是必选项。

第三个场景是可变参数。def func(*args)里的args就是一个元组,Python 这样做是故意的:函数接收到的不定长参数,不应该在函数内部被随意改动,用元组能保证参数不被意外篡改。

第四个场景是性能敏感的大规模数据处理。如果你要创建几百万个小结构对象,用元组能省下可观的内存和时间。不过我要提醒一句:不要为了那一点性能牺牲代码可读性,先判断语义,再考虑优化。

如果你想既享受元组的不可变性,又想让字段带有名字,可以用namedtuple或 Python 3.7+ 的dataclass。namedtuple特别适合那种“结构固定、字段不多、需要轻量表示”的数据,比如:

from collections import namedtuple Point = namedtuple('Point', ['x', 'y']) p = Point(3, 5) print(p.x, p.y) # 3 5

它本质上还是元组,能拆包、能哈希、能比较,只是多了属性名访问的便利。

3. 切片、拆包与“看上去很妙”的实操细节

3.1 列表切片:你会用,但不一定用对

切片是列表里最高频的操作之一,但很多人在细节上栽过跟头。基本语法是list[start:stop:step],左闭右开,也就是包含start位置,不包含stop位置:

nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] print(nums[2:5]) # [2, 3, 4] print(nums[:5]) # [0, 1, 2, 3, 4] print(nums[5:]) # [5, 6, 7, 8, 9] print(nums[::-1]) # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0] print(nums[::2]) # [0, 2, 4, 6, 8]

最容易犯的错误是把“切片结果”当成“视图”。很多从 NumPy 转过来的同学会用 NumPy 的习惯,认为切片是原数组的一个视图,改切片会影响原数组。Python 列表完全不是这样,列表切片会创建一个全新的列表对象,修改切片不影响原列表:

nums = [1, 2, 3, 4, 5] sub = nums[1:3] sub[0] = 99 print(nums) # [1, 2, 3, 4, 5],原列表没变

但要注意,如果列表里的元素本身是可变对象,比如嵌套列表,切片产生的新列表里的子列表仍然是引用,修改子列表内容时原列表也会变。这是浅拷贝的固有特性,一定要留个心眼。

切片还有一个特别实用的场景是浅复制:copy = nums[:]或者copy = nums.copy(),两者都能得到一份独立的新列表,但同样都是浅拷贝。如果你需要完全独立的深层复制,得用copy.deepcopy()。

3.2 元组的拆包:不止多变量赋值

拆包是元组最优雅的特性。最基本的用法是左右同时赋值:

coordinates = (10, 20) x, y = coordinates print(x, y) # 10 20

在此基础上可以实现一行交换变量:

a, b = b, a

这个写法背后的原理就是:右侧b, a先组成一个元组,然后按顺序拆包赋值给左侧的a, b。Python 求值顺序保证了取到的都是旧值,所以交换非常安全。

更进阶的是星号拆包,Python 3 引入的语法,处理“开头几个、中间一堆、最后几个”这类场景特别方便:

first, *rest = [1, 2, 3, 4, 5] print(first) # 1 print(rest) # [2, 3, 4, 5] head, *middle, tail = [1, 2, 3, 4, 5] print(head, middle, tail) # 1 [2, 3, 4] 5

注意*rest收集到的总是列表,不管被拆的对象是元组还是列表。这里的应用很广:比如拿列表的第一个元素当标题、剩余元素当正文;再比如递归处理嵌套结构时,head, *tail = items几乎是最简洁的写法。

函数调用时的参数解包也依赖这个机制:

def add(a, b, c): return a + b + c nums = [1, 2, 3] print(add(*nums)) # 6 params = {'a': 1, 'b': 2, 'c': 3} print(add(**params)) # 6

*用在函数调用的形参列表里表示收集位置参数,**表示收集关键字参数。每当你看到def func(*args, **kwargs),args就是元组,kwargs就是字典。这种设计保证了被收集进来的参数具有不可变性,函数内部没法意外篡改调用方传入的值。

3.3 “元组不可变”的边界情况

这里必须泼一盆冷水:元组的不可变是“浅层的”,不是“深层的”。如果元组里的某个元素本身是可变的,比如一个列表,那你依然可以修改这个列表的内容:

tup = (1, [2, 3], 4) tup[1].append(99) print(tup) # (1, [2, 3, 99], 4)

报错会迟到,但不会缺席:你确实不能给元组整体赋值tup[1] = [...],但你可以通过.append()或者tup[1][0] = 100去修改那个嵌套列表。这会让很多人大吃一惊,我第一次踩到这个坑的时候也懵了很久。

理解方式很简单:元组存储的是元素的“引用”,它保证的是引用关系不可变,也就是不能换掉某个位置上的元素对象,但元素对象本身如果是可变的,它的内部状态不受元组约束。

所以如果你真的需要一个“彻底不可变”的数据结构,光用元组不够,还得保证它内部所有元素都是不可变的,比如全部由数字、字符串、布尔值或其他不包含可变对象的元组组成。这正是元组可以用作字典键的条件:不仅要本身是元组,内部元素的类型也必须是可哈希的。当你尝试把(1, [2])作为字典键时,会得到TypeError: unhashable type: 'list',就是这个原因。

4. 常见问题与排查技巧实录

4.1 经典误区速查表

这几年带过不少新人,也帮人 review 过很多代码,下面这些问题是出现频率最高的,整理成一张速查表:

问题/报错原因解决方案
()以为创建了空元组,结果类型是元组但没法赋元素?()确实是空元组,但很多人写tup = ()后直接tup[0] = 1报错空元组很少用,需要空序列时用空列表[]通常更合理
写tup = (1)后发现type(tup)是 int单个元素没有逗号时,括号只表示优先级,不是元组必须写成tup = (1,),那个逗号才是元组的标志
TypeError: 'tuple' object does not support item assignment试图修改元组的某个元素改成列表,或者重新创建新的元组
元组里有列表,改列表内容后元组也变了元组只保证引用不变,不保证内部可变对象不变需要完全不可变时,避免在元组里放列表
可变默认参数共享状态def func(items=[])时多次调用会共享同一个列表默认参数用None,函数内部再创建列表
拆包时ValueError: too many values to unpack左边变量数量与右边元素数量不匹配用first, *rest = data或确认数据长度
列表作为字典键报unhashable列表可变,无法稳定哈希改成元组,或转成字符串

4.2 我踩过的几个坑

第一个坑是可变默认参数。写函数时图省事写了def process(data=[]),第一次调用往里加了数据一切正常,第二次调用发现在上次结果基础上继续追加,数据越滚越大。原因就是默认参数在函数定义时创建一次,后续所有调用共享同一个列表对象。正确写法是:

def process(data=None): if data is None: data = [] ...

第二个坑是在“需要保留原列表”时忘了复制,直接做了别名赋值。b = a不是复制,只是让b和a指向同一个列表对象,改其中一个另一个跟着变。需要独立副本时用b = a[:]或b = a.copy()。

第三个坑是列表的+=和元组的+=语义不同。列表的+=是原地修改,元组的+=看起来像修改,实际上创建了一个新元组:

lst = [1, 2] lst += [3] print(lst) # [1, 2, 3],原列表被修改 tup = (1, 2) tup += (3,) print(tup) # (1, 2, 3),实际上 tup 已经指向新元组

你可以用id()验证:列表+=前后id不变,元组+=前后id会变。这个区别在写某些算法题时容易踩坑,尤其是当你以为“反正都是原地操作”时。

4.3 性能实测记录

我用一段简单的代码实测了列表和元组的性能差异。创建 100 万个元素的小型容器,元组比列表快 10%~20%,内存占用少 10% 左右。迭代遍历也有微小差距,但说实话,在绝大多数业务代码里,这个差距不会成为瓶颈。

真正值得注意的是:不要为了省这点性能,把明显该用列表的场景硬改成元组。代码的可读性和数据语义的正确性,优先级远高于这种微小的性能优化。如果你真的到了要大规模创建固定结构数据的阶段,可以先用元组试试,能省多少内存一测便知。

import sys import timeit a = [0, 1, 2, 3, 4, 5] b = (0, 1, 2, 3, 4, 5) print(sys.getsizeof(a)) # 通常 88 左右 print(sys.getsizeof(b)) # 通常 72 左右 print(timeit.timeit('[0, 1, 2, 3, 4, 5]')) print(timeit.timeit('(0, 1, 2, 3, 4, 5)'))

这段代码你在自己机器上跑出来的数字很可能和我不同,版本、平台都会有影响,但趋势是一致的:元组更省,更快。

结尾

我个人在实际使用中体会最深的一点是:不要死记“列表可变、元组不可变”就完事,要形成一种条件反射——看到一个数据集合,先问自己“这个数据后续还要不要变”。要变就用列表,不变、或者变了会有风险,就用元组。这个习惯能帮你少写很多 bug。

最后再分享一个小技巧:当你写函数需要返回多个值时,别在文档里写“返回的是数组”,直接写清楚“返回的是元组,可以用两个变量接收”。文档里多写这一句,能帮后来的维护者省下大量翻代码的时间。很多问题不是 Python 难,而是我们太习惯用默认方式写,忽略了这些基础结构背后真正的设计意图。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询