元组 Tuple 与不可变序列

初学者常问:”元组不就是不能改的列表吗?为什么 Python 还要专门搞一个?” 简短答案:元组不是”列表的阉割版”,它有自己的核心用途——表示不可变的记录。列表适合”一堆同类型的东西”,元组适合”一件事的多个字段”。学完这一篇,你会理解为什么函数返回多值用元组、为什么字典 key 可以是元组、为什么大量 Python 代码里都是圆括号包起来的一小串数据。

一、创建元组

元组用小括号:

1
2
3
4
5
6
7
empty = ()
one = (1,) # 只有一个元素时必须加逗号!
two = (1, 2)
three = 1, 2, 3 # 括号可以省,逗号才是元组的标志

point = (3.14, 2.71)
person = ("咖飞", 25, "北京")

只有一个元素的元组必须带逗号,否则 Python 会当成”括号 + 表达式”:

1
2
3
a = (1)      # a 是整数 1
b = (1,) # b 是元组 (1,)
c = 1, # c 也是元组 (1,)

tuple() 构造函数

1
2
3
tuple("abc")           # ('a', 'b', 'c')
tuple([1, 2, 3]) # (1, 2, 3)
tuple(range(3)) # (0, 1, 2)

二、索引与切片

元组和列表在读取上几乎一样:

1
2
3
4
5
6
7
8
t = (10, 20, 30, 40, 50)

print(t[0]) # 10
print(t[-1]) # 50
print(t[1:3]) # (20, 30)
print(t[::-1]) # (50, 40, 30, 20, 10)
print(len(t)) # 5
print(30 in t) # True

区别在于:

1
2
t[0] = 99          # ❌ TypeError: 'tuple' object does not support item assignment
t.append(60) # ❌ AttributeError: no 'append'

元组不可变——一旦创建,不能修改、不能增加、不能删除元素。

三、元组的常用方法

元组的方法只有两个

1
2
3
t = (1, 2, 3, 2, 1)
print(t.count(2)) # 2
print(t.index(3)) # 2

对比列表 30+ 个方法,元组显得非常”精简”,这正是它的定位——尽量减少 API,只做”记录”用

四、元组的核心用途

1. 表达一条记录(结构化数据)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 一个点是 (x, y)
p = (3, 4)

# 一个用户是 (id, name, age)
user = (1, "咖飞", 25)

# 一批数据
users = [
(1, "咖飞", 25),
(2, "小王", 30),
(3, "小李", 28),
]

for uid, name, age in users:
print(f"{uid} {name} {age}")

这种”轻量记录”场景,元组比列表更合适——语义是”一个整体”,字段个数固定,顺序有意义。

2. 多值返回

Python 函数返回多值本质上就是返回元组:

1
2
3
4
5
def divmod_(a, b):
return a // b, a % b # 返回元组

q, r = divmod_(17, 5) # 元组解包
print(q, r) # 3 2

不用 return q, r 打包成元组,再解包,一步到位。

3. 作为字典的 key

元组不可变,所以可哈希,能做 dict 的 key(列表不行):

1
2
3
# 二维网格的位置 → 值
grid = {(0, 0): "起点", (2, 3): "宝箱"}
print(grid[(0, 0)])

4. 作为集合元素

同理,元组能进 set:

1
seen = {("a", 1), ("b", 2)}

5. 函数参数打包/解包

1
2
3
4
5
6
7
def sum_all(*args):     # args 是元组
return sum(args)

sum_all(1, 2, 3) # 6

nums = (1, 2, 3)
print(sum(nums)) # 也能直接用

五、序列解包

元组和”解包赋值”是最佳搭档:

1
2
3
4
5
6
7
8
9
10
11
# 简单解包
x, y = (3, 4)

# 允许任何可迭代对象
x, y = [3, 4]
a, b, c = "abc"

# 交换变量:一行搞定
a, b = 1, 2
a, b = b, a
print(a, b) # 2 1

扩展解包(Python 3.0+)

1
2
3
first, *rest = [1, 2, 3, 4]      # first=1, rest=[2, 3, 4]
*init, last = [1, 2, 3, 4] # init=[1, 2, 3], last=4
a, *mid, b = [1, 2, 3, 4, 5] # a=1, mid=[2, 3, 4], b=5

*rest 收集剩余的元素成为列表(不是元组,注意)。

忽略部分值

1
2
3
4
_, _, age = user       # 只想要 age

for _, name, _ in users:
print(name)

_ 只是约定俗成的”我不关心这个变量”,本身还是一个普通变量。

六、元组不可变 ≠ 内容不可变

这是新手最容易误解的一点:

1
2
3
4
5
t = ([1, 2], [3, 4])
t[0].append(99) # 元组本身没变,但内部的列表变了
print(t) # ([1, 2, 99], [3, 4])

t[0] = [7, 8] # ❌ 不能替换外层槽位

元组的”不可变”只保证内部指针不换,指向的对象本身如果是可变对象,仍然可以被修改。要真正的完全不可变,用 frozenset 或者 namedtuple 存不可变元素。

七、namedtuple:给元组的字段起名字

用元组的最大痛点:字段是按位置访问的(user[1]),代码可读性差。collections.namedtuple 解决这个问题:

1
2
3
4
5
6
7
8
9
10
11
from collections import namedtuple

Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)

print(p.x, p.y) # 3 4
print(p[0], p[1]) # 3 4,也能按索引
print(p) # Point(x=3, y=4)

# 解包也支持
x, y = p

namedtuple 的优点:

  • 不可变(继承了元组);
  • 有名字(比裸元组可读得多);
  • 兼容元组(旧代码不用改);
  • 内存开销小(跟普通元组一样,比 class 省内存)。

Python 3.6+ 还有更强大的 typing.NamedTuple

1
2
3
4
5
6
7
8
9
10
11
12
13
from typing import NamedTuple

class Point(NamedTuple):
x: float
y: float
label: str = "" # 默认值

def distance_to(self, other):
return ((self.x - other.x)**2 + (self.y - other.y)**2) ** 0.5

p = Point(3, 4)
q = Point(0, 0, label="origin")
print(p.distance_to(q)) # 5.0

八、元组 vs 列表:什么时候用哪个

场景 首选
一堆同类型元素,数量会变 list
一条固定字段的记录 tuple / namedtuple
函数返回多个值 tuple
作为 dict 的 key、set 的元素 tuple
数据在整个生命周期不变 tuple
需要 append / sort / 反复修改 list

心法列表是同质异量,元组是异质同量

九、性能对比

  • 元组比列表创建更快、占内存更小(列表要额外维护容量);
  • 迭代速度差不多;
  • 元组可缓存哈希,可以做 dict key;
  • 元组一般更适合作为常量数据。
1
2
3
import sys
print(sys.getsizeof((1, 2, 3, 4, 5))) # 元组约 80 字节
print(sys.getsizeof([1, 2, 3, 4, 5])) # 列表约 120 字节

(具体数字随平台变,趋势不变。)

十、一个综合例子:坐标聚类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# clustering.py
"""按整数网格给一组坐标分桶。"""

from collections import defaultdict, namedtuple

Point = namedtuple("Point", ["x", "y", "label"])


def bucket_key(p: Point, size: float) -> tuple:
"""把点归入 size*size 的网格,返回 (bx, by) 作为桶 key。"""
return (int(p.x // size), int(p.y // size))


def cluster(points: list[Point], size: float) -> dict:
buckets = defaultdict(list)
for p in points:
buckets[bucket_key(p, size)].append(p)
return dict(buckets)


if __name__ == "__main__":
pts = [
Point(1.2, 3.4, "A"),
Point(1.8, 3.9, "B"),
Point(4.5, 4.5, "C"),
Point(4.1, 4.7, "D"),
Point(9.9, 9.9, "E"),
]
result = cluster(pts, size=2.0)
for k, group in result.items():
print(f"网格 {k}: {[p.label for p in group]}")

代码用到了 namedtuple、元组做 dict key、defaultdict 等特性。

十一、常见陷阱

  1. 单元素元组必须加逗号(1) 是整数,(1,) 才是元组;
  2. 元组不可变但可以嵌套可变对象
  3. +* 会创建新元组(因为元组不能就地修改);
  4. t += (x,) 也是创建新元组,如果原元组被别的名字引用,那个名字看到的还是旧元组;
  5. 括号有时会被当成分组符(1) 不是元组,别忘了逗号;
  6. 元组不能 sortsorted(tuple) 返回列表,不是元组;要保留元组:tuple(sorted(t))

十二、小结与延伸阅读

  • 元组用小括号(可省略),单元素必须加逗号;
  • 元组不可变,索引/切片只读;
  • 核心用途:结构化记录、多值返回、字典 key、扩展解包;
  • 元组不可变 ≠ 内部对象不可变;
  • 需要给字段起名字用 namedtuple / typing.NamedTuple;
  • 数据不需要变就用元组,需要 append/sort 就用列表。

延伸阅读:

下一篇 字典 Dict 的使用 我们讲 Python 里另一个”半壁江山”的容器——字典。