列表和元组——Python 里的“爱情”:列表善变,元组长情
我刚学 Python 那会儿,总有人跟我说:“列表和元组差不多,差一个能不能改而已。”真到了写项目的时候才发现,这个“能不能改”背后藏着 Python 里一套非常讲究的设计哲学——一个像年轻时候的爱情,热烈、直接、随时随地可以调整;另一个像走过几十年的伴侣,稳定、可靠、说出去的话就不会变。所以 Python 官方给它们起了个非常形象的外号:列表是“可变序列”(mutable sequence),元组是“不可变序列”(immutable sequence)。中文社区里也有个特别接地气的说法——列表善变,元组长情。
这个比喻不是硬凑的,是真的能从语言层面找到依据。列表用方括号[],元组用圆括号(),这俩符号本身就暗示了它们的气质:方括号像一扇随时能打开的门,圆括号像一对合拢的手掌。今天这篇就围绕这个“爱情”比喻,把列表和元组的底层机制、操作方法、性能差异、使用禁忌聊透,让正在学 Python 基础语法的朋友一次搞明白:什么时候该用善变的列表,什么时候该用长情的元组。
如果你是刚看完 Python 安装教程、正准备往深处走的新手,这篇文章可以直接当你的第四课来读。我会把每个知识点掰开揉碎,配合大量能直接复制的代码示例,读完你就能在真实项目里做出合理选型,而不是只会背“列表可变、元组不可变”这一句口诀。
1. 列表为什么“善变”?——把可变性拆开看清楚
先说结论:列表的“善变”指的是它的内容可以在原地修改——可以追加元素、删除元素、替换元素、调整顺序,而且修改之后,列表在内存中的“门牌号”(也就是对象 id)不会变。这意味着你操作的是同一个列表对象,不是重新造了一个。
1.1 原地修改:append、insert、pop 都动了什么
Python 列表底层是一个动态数组(dynamic array),你可以把它想象成一条会自己变长的停车位划线区。初始时可能只划了 4 个车位,但车多了它会自动在旁边多划几个,而且整个区域的地址不变,车还是停在同一片地方。
看这段代码:
nums = [1, 2, 3] print(id(nums)) # 输出某个内存地址,比如 140261892292928 nums.append(4) nums.insert(0, 0) print(nums) # [0, 1, 2, 3, 4] print(id(nums)) # 地址和上面完全一样append是在末尾添一个元素,insert是在指定下标位置插入。这两个操作执行完后,id(nums)没有变化,说明是典型的内存原地修改。
pop和remove也一样:
nums.pop() # 弹出最后一个,返回 4 nums.remove(0) # 删除第一个值为 0 的元素 print(nums) # [1, 2, 3]我见过不少从 C 语言转过来的朋友,一开始总有个疑惑:“数组不是定长的吗?Python 列表怎么能随便加东西?”这就要说到动态数组的扩容机制:当元素个数超过当前容量时,Python 会申请一块更大的内存(通常是原容量的 1.125 倍左右),然后把旧元素全部拷贝过去。这个操作是自动完成的,用户无感知。也正因为容量会预分配,列表的append操作平均时间复杂度是 O(1)——注意是“平均”,偶尔触发扩容时会慢一下。
1.2 切片操作:列表最“善变”的高光时刻
切片(slice)是列表操作里最灵活、也最容易让人眼前一亮的功能。它用[start:stop:step]的语法从列表里截取一段,而且这个截取支持连续读、跳着读、倒着读。
alist = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] alist[2:5] # 下标 2 到 4,结果是 [2, 3, 4] alist[:3] # 从头到下标 2,结果是 [0, 1, 2] alist[5:] # 从下标 5 到末尾,结果是 [5, 6, 7, 8, 9] alist[::2] # 每隔一个取一个,结果是 [0, 2, 4, 6, 8] alist[::-1] # 整个列表倒过来,结果是 [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]切片有个容易忽略的细节:步长为负数时,start和stop的默认值会互换。比如alist[::-1],默认就是从最后一个元素开始,一直取到第一个元素,所以实现了翻转。
切片还能直接用来批量替换元素,这是“善变”的极致体现:
alist[2:5] = [20, 30, 40, 50] print(alist) # [0, 1, 20, 30, 40, 50, 5, 6, 7, 8, 9]注意这里我把 3 个元素替换成了 4 个,列表长度会自动变化。如果赋值的是一堆空列表,就等价于删除:
alist[2:5] = [] print(alist) # [0, 1, 5, 6, 7, 8, 9]这个行为比del alist[2:5]稍微隐蔽一点,但原理一致。
1.3 内存模型:为什么列表里存的是“引用”而不是“值”
很多新手会在这里翻车:list2 = list1之后,修改list2却发现list1也变了。原因在于 Python 的变量名本质是指向对象的标签,列表对象本身只有一个,两个变量名指向的是同一块内存。
a = [1, 2, 3] b = a # 把 a 这个“标签”贴到同一个列表上 b.append(4) print(a) # [1, 2, 3, 4],a 也跟着变了想真正复制一份独立的列表,要使用切片b = a[:]或者b = a.copy();如果列表里嵌套了子列表,浅拷贝还不够,得用copy.deepcopy:
import copy matrix = [[1, 2], [3, 4]] shallow = matrix.copy() # 外层新列表,内层还是同一个子列表对象 deep = copy.deepcopy(matrix) # 完全独立复制 shallow[0].append(99) print(matrix) # [[1, 2, 99], [3, 4]],matrix 被污染了 print(deep) # [[1, 2], [3, 4]],deep 不受影响这是一个非常重要的实战细节。做数据清洗、算法题时,如果没搞清浅拷贝和深拷贝的区别,经常会出现“明明复制了一份,改着改着把原数据也改了”的诡异 bug。
2. 元组为什么“长情”?——不可变性的三重表现
如果说列表是“写下来还能改的草稿”,元组就是“刻在石头上的誓言”——一旦创建,内部的元素引用就不能再增、删、改。用专业术语说,元组是不可变序列。
2.1 元组的创建方式与“裸元组”陷阱
最常见的创建方式是圆括号:
t1 = (1, 2, 3) t2 = () # 空元组 t3 = (1,) # 单元素元组,注意这个逗号这里有一个所有初学者都会踩的坑:t = (1)并不是元组,它只是一个整数 1,因为圆括号在 Python 里还承担着“分组”的作用,(1)就是普通的数字。想要创建单元素元组,必须在元素后面加一个逗号(1,)。
还有一种创建方式不显眼但很实用:
t4 = 1, 2, 3 print(type(t4)) # <class 'tuple'>逗号才是元组的本质,圆括号经常只是装饰。这个知识点会引出后面要讲的“元组解包”,也是 Python 里非常优雅的语法糖。
2.2 不可变到底意味着什么:不能增删改,但可以整体替换
先看最直白的报错场景:
t = (1, 2, 3) t.append(4) # AttributeError: 'tuple' object has no attribute 'append' t[0] = 100 # TypeError: 'tuple' object does not support item assignment del t[0] # TypeError: 'tuple' object doesn't support item deletion这三行每一行都会直接抛异常。元组没有append、insert、pop、remove这些修改方法,也没有__setitem__这个特殊方法,所以任何“原地修改”的念头都是非法的。
但要注意,“不可变”不等于“不能重新赋值”。下面的操作是合法的:
t = (1, 2, 3) t = (4, 5, 6) # 这不是修改元组,而是让变量 t 指向一个全新的元组对象就像你戴了十年的手表不能换零件,但你可以整块换一块新手表。变量名只是一个标签,标签可以撕下来贴到别的对象上。这个区别在函数传参时特别重要——如果函数内部对参数重新赋值,外部变量不会受影响;但如果函数内部修改的是可变对象的内部内容,外部变量就会跟着变。
2.3 元组里的隐藏可变性:元素本身是列表时
元组“长情”是指它保存的引用列表不能变,但不代表这些引用指向的对象不能变。如果元组的某个元素恰好是一个列表,这个列表的内容是可以被修改的:
t = (1, [2, 3], 4) t[1].append(99) print(t) # (1, [2, 3, 99], 4),元组没“变”,但元组里的列表变了这个细节在面试中几乎必考,在实际开发中也经常害人。比如你在元组里存了一个列表作为配置数据,某段代码不小心改了那个列表,元组看起来还是那个元组,但行为已经变了。这就是为什么“不可变”要打引号——更准确的说法是:元组的浅层不可变。
2.4 元组解包:把“长情”变成优雅的代码
元组解包(tuple unpacking)是 Python 里极其好用的特性,语法极其自然:
point = (3, 5) x, y = point print(x, y) # 3 5 a, b = b, a # 经典的交换变量值写法,本质就是元组打包然后解包这个语法背后就是“逗号创建元组”的机制:b, a先临时打包成一个元组,然后解包赋值给左边。Python 的return语句也常配合这个特性,一次性返回多个值:
def get_user(): return "张三", 28, ["篮球", "摄影"] name, age, hobbies = get_user()你完全可以用列表来返回多个值,但元组更合适——返回的数据是“完整的一份结果”,调用方不需要也不能在你返回之后偷偷改。这种语义上的清晰,正是元组在实际项目中最大的价值。
还有一个解包玩法叫星号解包,Python 3 之后才支持,特别好用:
first, *rest = [1, 2, 3, 4, 5] print(first) # 1 print(rest) # [2, 3, 4, 5]它能把列表或元组的头部和剩余部分一次性拆开,配合循环非常顺手。
3. 列表和元组的区别,从性能到语义的全面对比
标题里的“爱情”比喻已经点出了核心区别,但真要在代码里做选择,光有比喻不够。我把两者的差异按五个维度整理出来,这节内容可以直接当参考表用。
3.1 五维对比:可变性、性能、哈希性、占用内存、语义
| 维度 | 列表 list | 元组 tuple |
|---|---|---|
| 可变性 | 可变,支持增删改 | 不可变,不能增删改 |
| 存储开销 | 更大(需要预留扩容空间) | 更小(精确分配内存) |
| 性能 | 稍慢(动态扩容、引用维护) | 稍快(结构更简单) |
| 哈希性 | 不可哈希,不能做字典键 | 元素均为可哈希时可哈希,可做字典键 |
| 语义 | “动态集合”,数据会变 | “固定记录”,结构不变 |
内存和性能差异在数据量很小时几乎感觉不到,但如果是百万级元素,元组的迭代速度通常会快 10% 到 20%,内存占用也少一截。原因很直接:元组的内存是固定大小、一次性分配的,而列表需要维护一个“容量”概念,还要为可能的扩容预留空间。
3.2 哈希值:元组能做字典键,列表不能
这个是列表和元组区别里最“硬核”的一个知识点,很多面试官喜欢问。
Python 的字典键要求对象是可哈希的(hashable),也就是对象必须有稳定的哈希值。列表是可变的,内容一变哈希值就乱了,所以它不能做字典键。元组不可变,只要元组内的所有元素都可哈希,这个元组就是可哈希的,能安全地作为字典键使用。
# 合法:元组作为字典键 positions = { (0, 0): "起点", (1, 3): "宝箱", (4, 2): "出口", } print(positions[(1, 3)]) # 宝箱 # 非法:列表不能作为字典键 positions = { [0, 0]: "起点", # TypeError: unhashable type: 'list' }这个特性在游戏开发、路径规划、表格数据处理中特别常用——坐标、行列号这种“固定结构”天然适合用元组表示。
3.3 安全性与语义表达:为什么函数默认参数建议用元组
可变对象作为函数默认参数,是 Python 里最经典的坑之一。看这段代码:
def add_task(task, tasks=[]): tasks.append(task) return tasks print(add_task("写博文")) # ['写博文'] print(add_task("改bug")) # ['写博文', '改bug'],默认值被记住了!问题在于:函数定义时,默认列表[]只创建一次,之后每次调用如果没传新列表,用的都是同一个列表对象。第一次调用往里加了元素,第二次调用看到的自然就是带元素的结果。
如果把默认值改成元组,这个坑就不会发生——元组不可变,就是想往里塞东西也塞不了:
def add_task(task, tasks=()): tasks = tasks + (task,) # 新建一个元组,原元组不受影响 return tasks这里tasks = tasks + (task,)创建了一个全新的元组对象,原默认值永远不变。安全性一下子就上来了。
从语义角度说,元组天然适合表达“一个固定的记录结构”,比如坐标点、颜色值 (r, g, b)、数据库查询返回的一行数据。列表适合表达“数量会变的一组东西”,比如购物车、待办事项、日志行。选错容器,代码会显得别扭,还会埋下“某个函数不经意间改了共享数据”的隐患。
3.4 一道经典题的生动演示:+=在列表和元组上的不同命运
+=这个操作符在列表和元组上的表现,可以说是“善变”与“长情”最生动的对比:
lst = [1, 2, 3] lst += [4, 5] print(lst) # [1, 2, 3, 4, 5],同一个列表,原地扩充了 tup = (1, 2, 3) tup += (4, 5) print(tup) # (1, 2, 3, 4, 5),看起来变了,但 id(tup) 已经变了列表的+=相当于调用了extend方法,原地修改;元组的+=则是创建了一个全新元组,再把变量名绑定过去。你可以打印id验证:元组的id前后完全不同。
有个著名面试题问:元组里的列表用+=会不会报错?答案是会报错,但列表已经变了:
t = (1, [2, 3], 4) try: t[1] += [99] except TypeError as e: print(e) # 'tuple' object does not support item assignment print(t) # (1, [2, 3, 99], 4)报错是因为元组不允许“把新列表赋回下标 1”,但+=内部的逻辑是先修改列表内容,再尝试把结果赋回元组位置。这个“先斩后奏”的行为非常魔鬼,理解了元组的浅层不可变之后,这题就再也骗不到你了。
4. 什么时候用列表,什么时候用元组?——实战选型思路
看完对比,最后要落到实际代码里:真实项目里到底怎么选?我总结了一套简单好记的判断标准,分三种情况。
4.1 口诀式判断标准:结构固定用元组,数量动态用列表
最简单的判断口诀是:
- 如果数据量会变——比如用户列表、日志记录、缓存结果,用列表。
- 如果形状不会变——比如坐标、RGB 颜色、日期 (年, 月, 日)、数据库一行记录,用元组。
这个口诀和前面“爱情”比喻完全一致:列表在成长,在变化;元组自始至终保持同一副模样。写代码和选伴侣一样,三观合不合,很多时候一开始就写在类型里了。
4.2 综合案例:从需求描述推导容器选型
假设要写一个小工具,管理一个“英雄角色信息”系统,包含角色的名字、职业、初始血量,以及一个可以持续获得和消耗的物品背包。
这里显然有两种不同性质的容器:
# 角色基础信息:创建后不该变,用元组 base_info = ("亚瑟", "战士", 3000) # 背包:动态增减,用列表 backpack = ["治疗药水", "铁剑", "木盾"] backpack.append("魔法书") backpack.remove("木盾")如果把角色基础信息做成列表,可能某个函数手滑就base_info[0] = "改名了",整个数据的一致性就崩了。用元组,编译器替你守住“不该变的不变”这条规则,这是类型设计带来的最直接收益。
用 Python 的collections.namedtuple还能让元组更“高级”——字段带名字,可读性提升一个档次:
from collections import namedtuple Hero = namedtuple("Hero", ["name", "job", "hp"]) hero = Hero("亚瑟", "战士", 3000) print(hero.name) # 亚瑟 print(hero.job) # 战士 # 而且它依然是元组,支持解包、索引 name, job, hp = hero这可以说是元组的“升级形态”,既有元组的不可变安全性和低开销,又有类似类的属性访问体验,非常适合表达固定结构的业务数据。
4.3 需要“伪列表”语义时:使用tuple()保护数据
有时候你拿到一个列表,是别人传进来的,或者是从文件里读出来的,但你接下来要把这份数据当作“整体”传给下游函数,并且不希望下游函数意外修改它。这时候可以临时转成元组,相当于给数据加了一层只读保护:
config_version = "v1.0" allowed_users = ["alice", "bob", "carol"] safe_users = tuple(allowed_users) def check_user(user, user_pool): # 这里哪怕写了 user_pool.append(...) 也不会影响外部 allowed_users return user in user_pool print(check_user("alice", safe_users)) # True不过要诚实地说一句:Python 没有绝对的强制只读,真要绕过也有很多方法。元组的意义更多是约定和提醒——看到元组,所有维护代码的人都明白:这里面的数据别乱动。
4.4 细节提醒:什么时候元组反而麻烦
元组也不是银弹,有些场景下它确实不够用。比如你维护一个爬虫任务队列,任务可能随时插队、取消、调整优先级,这种场景用列表或者collections.deque更合适。再比如你要经常对一组数据做排序、去重、倒序,列表的list.sort()、sorted(lst)用起来比元组顺手得多——元组因为不可变,想排序只能sorted(tup)返回新列表,或者手动转列表操作再转回元组,多一层不必要的来回。
所以选型不是“元组更高端,所以都用元组”,而是看数据本质:倾向于“被使用、被读取”,用元组;倾向于“被构建、被修改”,用列表。这个标准几乎适用于所有场景。
5. 从零开始:先跑起来再深挖,顺便避开这三个新手坑
新手阶段最怕的不是概念不懂,而是“明明按教程敲了,结果第一行就报错”。我这里给三条最容易在入门阶段绊倒人的踩坑经验,全是实操教训。
5.1 花括号、圆括号、方括号分不清的破局方法
Python 里三种集合形态新手最容易混:
- 列表
[1, 2, 3] - 元组
(1, 2, 3)或1, 2, 3 - 集合
{1, 2, 3}
花括号创建的是集合(或字典),它有两个重要特征:无序、自动去重。如果你用{1, 2, 3}却指望它像列表一样保持顺序、允许重复,就会踩坑:
s = {3, 1, 2} print(s) # {1, 2, 3},顺序不保证 s.add(3) print(s) # 还是 {1, 2, 3},不会出现两个 3所以看到方括号下意识想到“可以改”,看到圆括号想到“固定”,看到花括号想到“无序去重”,这套条件反射建立起来,三个容器就不会再混淆。
5.2 元组单元素漏写逗号:一个让老手也会笑出声的 bug
我再重复一遍这个坑,因为它真的最高频、最低级、也最坑:
value = (5) print(type(value)) # <class 'int'>,不是元组! value = (5,) print(type(value)) # <class 'tuple'>新手写元组时,最后一个元素后面拖个逗号,看起来“多余”,实际上才是元组的标志。更隐蔽的情况是在函数返回时:
def get_value(): return 5,如果你只写return 5,调用方接到的是整数;写成return 5,,调用方接到的是(5,)元组。这个逗号丢没丢,直接决定下游代码能不能解包。建议在项目里定个规矩:函数返回固定多条数据时,统一用元组并显式加括号,比如return (5, 6),既清楚又没有歧义。
5.3 修改列表时不要“边遍历边删除”
新手写数据过滤时最常犯的错是这样:
nums = [1, 2, 3, 4, 5, 6] for n in nums: if n % 2 == 0: nums.remove(n) print(nums) # 你可能以为会得到 [1, 3, 5],实际是 [1, 3, 5],这里看似没问题如果数据换成[1, 2, 4, 5, 6],再跑一次就会发现结果不对,因为遍历时索引会前移,删除元素后有些元素会被跳过:
nums = [1, 2, 4, 5, 6] for n in nums: if n % 2 == 0: nums.remove(n) print(nums) # [1, 4, 5]——4 被跳过了!原因很简单:遍历到 2 时把它删了,列表变成 [1, 4, 5, 6],但迭代器内部的下标已经走到了 1 位置,下一次取到的就是 5,4 被跳过。
正确做法是包装一份副本或者使用推导式:
nums = [1, 2, 4, 5, 6] nums = [n for n in nums if n % 2 != 0] print(nums) # [1, 5]列表推导式是 Python 里最值得花十分钟彻底学会的语法之一,能把“创建新列表并过滤”压缩成一行,可读性极好,性能也快。它本质上就是“善变”的列表和新列表之间的高效桥梁。
6. 进一步掌握:切片、推导式与排序——列列表和元组都适用的进阶技巧
列表和元组都属于“序列类型”,所以很多操作是通用的——切片、索引、in判断、len()、+拼接、*重复,在两者身上都能用。这一节把使用频率最高的几个技巧集中讲透。
6.1 切片在元组上的同时效果
元组也支持切片,而且返回的结果仍然是元组:
t = (0, 1, 2, 3, 4, 5) print(t[1:4]) # (1, 2, 3) print(t[::-1]) # (5, 4, 3, 2, 1, 0)所以“想反转一个元组”,直接切片翻转即可,不需要先转列表。同样,字符串也有一样的切片语法,三种序列在此刻表现完全统一。学会一次,三种类型通吃。
6.2 列表推导式与元组生成器只差一个括号?
列表推导式写多了,你可能自然想到“元组推导式”?直接写(x for x in ...)得到的并不是元组,而是生成器对象:
gen = (x * x for x in range(5)) print(type(gen)) # <class 'generator'> print(tuple(gen)) # (0, 1, 4, 9, 16),用 tuple() 转换才得到真元组这是新手很容易踩的另一个坑。想直接用推导式得到元组,写法是这样的:
t = tuple(x * x for x in range(5))等价于先把生成器转成元组。这个细节看起来只是“多转了一下”,但在处理大数据时反而成了优势:生成器是惰性求值,不会一次性在内存里堆出所有元素,性能友好。
6.3 sorting:列表原地排,元组排完变列表
列表有sort()方法,原地排序,不返回新对象;元组不能原地排序,只能用全局函数sorted():
nums = [3, 1, 2] nums.sort() print(nums) # [1, 2, 3] t = (3, 1, 2) sorted_t = sorted(t) print(sorted_t) # [1, 2, 3],是列表不是元组 print(tuple(sorted_t)) # 想保持元组类型,手动转一下如果不关心返回类型,sorted()对两种序列都能直接使用,还支持reverse=True参数和key关键字。key的用法值得单独练一下:
words = ["banana", "apple", "cherry", "date"] sorted_by_len = sorted(words, key=len) print(sorted_by_len) # ['date', 'apple', 'banana', 'cherry']6.4 用*解包操作合并多个列表或元组
Python 3.5 之后,*解包不仅能用于函数参数,还能直接用在字面量里拼接序列:
list_a = [1, 2] list_b = [3, 4] merged_list = [*list_a, *list_b] print(merged_list) # [1, 2, 3, 4] tup_a = (1, 2) tup_b = (3, 4) merged_tuple = (*tup_a, *tup_b) print(merged_tuple) # (1, 2, 3, 4)这个写法比list_a + list_b更灵活,因为可以在任意位置插入新元素:
merged = [0, *list_a, 99, *list_b] print(merged) # [0, 1, 2, 99, 3, 4]对元组也能做类似操作,比如在一个元组中间插入元素再生成新元组:
t = (1, 3) new_t = (*t[:1], 2, *t[1:]) print(new_t) # (1, 2, 3)这类小技巧在你处理数据合并、参数透传时非常实用,能省掉大量临时变量。
7. 写在最后的个人体会
列表和元组的区别,是 Python 入门阶段最值得你停下来多想几分钟的知识点。它不只是一个语法规则,而是理解“引用 vs 对象”“可变 vs 不可变”“修改 vs 重新赋值”这些底层概念的绝佳入口。搞懂了它,后面学字典的键值约束、函数的默认参数、类里的__hash__、甚至多线程下数据共享的安全问题,都会顺很多。
我在实际项目里的体会是:新人往往偏爱列表,老手则越来越喜欢元组。因为元组提醒你“结构固定”这个事实,强制你写出更稳的代码;列表则给你无限自由,同时也给了你搞乱自己的机会。两者没有高下之分,用对场景就是好代码。
最后分享一个我自己常备的小技巧:在写函数、写配置、写接口返回值的时候,先问自己一句“这个数据,将来会被修改吗?”答案是“不会”——就用元组;答案是“会”——才用列表。这个问题想清楚一遍,代码的健壮性和可读性都会有肉眼可见的提升。这篇基础课就到这里,接下来可以继续往字典、集合这些进阶数据结构上走,每一步踩实了,后面的 Python 之路会走得特别稳。