Python 元组与列表:从入门到踩坑
列表可变(mutable),元组不可变(immutable)——这一句话就是两者的核心区别。但真正容易出 bug 的,是这句话背后隐藏的细节。本文从基础讲到实战踩坑,并附 5 道自测题。
一、基本概念
元组(tuple)和列表(list)都是有序序列,可以存放任意类型的元素,都支持索引、切片、遍历。
核心区别一句话:
列表可变(mutable),元组不可变(immutable)
二、创建方式
# 列表:方括号
fruits = ["苹果", "香蕉", "橙子"]
nums = [1, 2, 3]
empty = []
# 元组:圆括号
point = (3, 5)
rgb = (255, 128, 0)
empty_tuple = ()
# 注意:单个元素的元组必须加逗号!
t1 = (42,) # 这是元组,长度为 1
t2 = (42) # 这只是整数 42,括号被当作运算符括号
# 逗号才是关键,下面也是元组
t3 = 1, 2, 3
三、共同操作
lst = [10, 20, 30]
tup = (10, 20, 30)
# 索引(从 0 开始,支持负索引)
lst[0] # 10
tup[-1] # 30
# 切片
lst[1:] # [20, 30]
tup[:2] # (10, 20)
# 长度、成员判断、计数、查找
len(lst) # 3
20 in tup # True
lst.count(10) # 1
tup.index(30) # 2
# 拼接、重复(生成新对象)
lst + [40] # [10, 20, 30, 40]
tup * 2 # (10, 20, 30, 10, 20, 30)
# 解包
x, y = point # x=3, y=5
a, *rest = [1, 2, 3, 4] # a=1, rest=[2, 3, 4]
四、列表专属的可变操作
lst = [1, 2, 3]
lst.append(4) # 尾部追加 → [1, 2, 3, 4]
lst.insert(1, 99) # 指定位置插入 → [1, 99, 2, 3, 4]
lst.extend([5, 6]) # 合并另一个可迭代对象
lst.remove(99) # 按值删除(第一个匹配项)
lst.pop() # 弹出末尾元素并返回
lst.pop(0) # 弹出指定索引
lst.sort() # 原地排序
lst.reverse() # 原地反转
# 元组不能做任何上述操作
tup[0] = 100 # TypeError!
元组想"修改",只能生成新的:
tup = (1, 2, 3)
tup = tup + (4,) # 新元组 (1, 2, 3, 4)
lst2 = list(tup) # 转成列表再改
lst2.append(5)
tup = tuple(lst2) # 再转回来
五、什么时候用哪个?
| 场景 | 选择 |
|---|---|
| 元素会增删改(购物车、任务队列) | 列表 |
| 固定结构的数据(坐标、RGB 颜色、经纬度) | 元组 |
| 作为字典的键 | 必须元组(列表不可哈希) |
| 函数返回多个值 | 元组(return x, y) |
| 需要防止误改 | 元组 |
# 经典应用:元组作字典键
locations = {
(39.9, 116.4): "A城市",
(31.2, 121.5): "B城市",
}
# 列表不行
d = {[1, 2]: "x"} # TypeError: unhashable type: 'list'
性能方面:元组创建更快、占内存略小,且因为不可变可以被解释器缓存优化。大批量只读数据用元组更划算。
六、几个容易踩的坑
坑 1:"不可变"是浅层的
元组里装了列表,列表内容仍可改:
t = (1, [2, 3])
t[1].append(4) # 完全合法!t 变成 (1, [2, 3, 4])
t[1] = [9] # 报错,这改的是元组本身
不可变性约束的是容器自身的结构(能不能替换/增删元素),不管元素内部是什么。
坑 2:+= 对列表是原地修改,对元组是新建对象
lst = [1, 2]; print(id(lst)); lst += [3] # id 不变
tup = (1, 2); print(id(tup)); tup += (3,) # id 变了,新对象
自测:5 道练习题
第 1 题(热身:创建与索引)
创建一个元组 t,包含元素 1, 2, 3,然后:
- 打印它的第二个元素(不用
t[1]这种写法,用负索引) - 尝试输出
(5)和(5,)的type(),解释为什么不同
第 2 题(解包)
有这样一个列表:
scores = [88, 92, 75, 63, 99, 81]
用一行解包语句,把第一个分数给 first,最后一个给 last,中间的收集成一个列表 middle。
第 3 题(可变 vs 不可变)
不运行代码,先预测输出,再验证:
t = (1, [2, 3])
t[1] = t[1] + [4]
print(t)
lst = [1, (2, 3)]
lst[1] = lst[1] + (4,)
print(lst)
两段代码,哪个能跑通,哪个报错?为什么?
第 4 题(实战:元组作字典键)
写一个函数 group_by_position(points),接收一个坐标列表:
points = [(3, 5), (0, 0), (3, 5), (-1, 2), (0, 0)]
返回一个字典,统计每个坐标出现的次数:
{(3, 5): 2, (0, 0): 2, (-1, 2): 1}
思考:为什么这里如果传入 [[3, 5], [0, 0], ...](列表的列表)就不行?
第 5 题(坑题:交换与引用)
下面代码想实现"交换 a、b 两个列表的内容",但写法值得推敲。找出问题并给出两种修复方案(一种是真正交换对象内容,一种是交换变量绑定):
a = [1, 2, 3]
b = [4, 5, 6]
a, b = b[:], a[:] # 程序员以为这样交换了
a.append(100)
print(a) # 期望 [4, 5, 6, 100]?
print(b)
参考答案
第 1 题
t = (1, 2, 3)
# 负索引:-1 是最后一个,-2 是倒数第二个
print(t[-2]) # 2
print(type((5))) # <class 'int'> —— 括号被当作数学运算的分组符号
print(type((5,))) # <class 'tuple'> —— 逗号才是元组的标志
要点:(5) 只是一个加了括号的整数,5 == (5) 为 True。元组的本质是逗号,(5,) 甚至 5, 都是元组。
第 2 题
scores = [88, 92, 75, 63, 99, 81]
first, *middle, last = scores
print(first) # 88
print(middle) # [92, 75, 63, 99]
print(last) # 81
要点:星号表达式 *middle 收集剩余元素,得到的是列表(即使对元组解包也是列表)。星号只能出现一次,位置可以在头部、尾部或中间。
第 3 题
第一段报错,第二段能跑通。
t = (1, [2, 3])
t[1] = t[1] + [4] # ❌ TypeError
# 虽然右边算出了新列表 [2, 3, 4],
# 但赋值是在"替换元组的元素",动的是元组本身 → 不允许
lst = [1, (2, 3)]
lst[1] = lst[1] + (4,) # ✅ 合法,lst 变成 [1, (2, 3, 4)]
# 替换的是"列表的元素",列表可变 → 没问题
# 被放进去的 (2, 3, 4) 是一个新元组,旧元组 (2, 3) 没被修改
而这样写反而合法:
t = (1, [2, 3])
t[1].append(4) # ✅ 改的是元组里的列表对象,不是元组结构
print(t) # (1, [2, 3, 4])
第 4 题
def group_by_position(points):
result = {}
for p in points:
result[p] = result.get(p, 0) + 1
return result
points = [(3, 5), (0, 0), (3, 5), (-1, 2), (0, 0)]
print(group_by_position(points))
# {(3, 5): 2, (0, 0): 2, (-1, 2): 1}
为什么列表不行:字典的键必须是可哈希(hashable)的。哈希的前提是对象内容不能变——否则存进去之后键被改了,就再也找不到对应的值了。元组不可变,所以可哈希;列表可变,哈希值无法保证稳定,所以报 unhashable type: 'list'。
补充一个实战细节:元组可哈希的前提是它的元素也可哈希。((1, 2), [3]) 这样的元组照样不能作键,因为它里面装了列表。
进阶写法,用标准库一行搞定:
from collections import Counter
Counter(points) # Counter({(3, 5): 2, (0, 0): 2, (-1, 2): 1})
第 5 题
问题所在:a, b = b[:], a[:] 中的 [:] 是画蛇添足——它多创建了两个副本,浪费内存,还容易让人误以为不写就不行。直接写:
a, b = b, a
这行代码先把右侧整体求值(拿到旧 b、旧 a 两个引用),再同时重新绑定,不需要任何副本。
但如果目的是"交换两个列表对象的内容"(让所有引用这两个列表的其他变量也看到变化),就得用切片赋值修改对象本身:
a = [1, 2, 3]
b = [4, 5, 6]
temp = a[:]
a[:] = b
b[:] = temp
两种方案对比:
| 方案 | 写法 | 效果 |
|---|---|---|
| 交换变量绑定 | a, b = b, a |
只有 a、b 这两个名字换了指向,其他引用旧对象的变量不受影响 |
| 交换对象内容 | a[:] = b; b[:] = temp |
所有引用这两个列表的变量都能看到变化 |
核心教训:Python 的赋值永远是绑定引用,不拷贝内容。x = y 之后改 x,y 指向的对象会不会变,取决于你是重新绑定(x = ...)还是原地修改(x.append、x[:] = ...)。
总结
- 列表:动态数组,随用随改,日常 90% 的场景。
- 元组:只读记录,结构固定,能哈希、可作字典键。
- 元组的"不可变"是结构层面的,不约束元素内部状态。
- 面试和实际 bug 里出镜率最高的两个知识点:浅层不可变(第 3 题)和引用 vs 内容(第 5 题),值得反复回味。

浙公网安备 33010602011771号