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 题),值得反复回味。
posted @ 2026-09-16 23:03  amu2018  阅读(14)  评论(0)    收藏  举报