前言
tuple(元组)是 Python 的基础数据类型之一,官方文档把它归在「序列类型」下,和list并列。它的概念其实一句话能说清:一个不可变的有序序列。但就是「不可变」这三个字,衍生出了一串连锁反应——不能增删改、可以算哈希、能当字典的键、能放进集合、适合表达固定记录。
围绕元组有两个概念经常被说反。第一是「元组就是带圆括号的列表」,这是错的:圆括号只是可选的装饰,真正定义元组的是逗号,1, 2是元组,(1)是整数。第二是「元组一定可哈希」,这也只对了一半:元组的哈希要求它里面每个元素都可哈希,(1, [2])这样的元组照样不可哈希。
本文从概念入手,讲清元组「是什么、为什么不可变、不可变带来什么」,再覆盖创建、用法、与列表的取舍,最后介绍具名元组(namedtuple)这一实用扩展。
一、元组的概念:不可变的有序序列
官方对序列的通用操作,元组几乎全都支持:下标、切片、len、in、迭代、+、*、比较。它不支持的,是「修改自身」这一类。
| 特性 | 元组是否支持 |
|---|
按下标取值t[0] | 支持 |
切片t[1:3] | 支持 |
len/in/ 迭代 | 支持 |
拼接+、重复* | 支持(返回新元组) |
元素赋值t[0] = x | 不支持 |
append/remove/sort | 不支持 |
# 适用于 Python 3.8+
t = (10, 20, 30)
print(t[0]) # 10
print(t[0:2]) # (10, 20)
print(len(t)) # 3
print(20 in t) # True
print(t + (40,)) # (10, 20, 30, 40)
print(t * 2) # (10, 20, 30, 10, 20, 30)
print(t) # (10, 20, 30),原元组始终不变看最后一行:所有运算都没有改动t,这是元组的核心语义。
二、逗号才是元组的标志
这是入门阶段最容易栽跟头的地方。
# 适用于 Python 3.8+
a = 1, 2, 3 # 元组
b = (1, 2, 3) # 元组
c = (1) # 整数!圆括号只是分组
d = (1,) # 元组,逗号是关键
e = 1, # 元组,括号可以省
print(type(a).__name__) # tuple
print(type(c).__name__) # int
print(type(d).__name__) # tuple
print(type(e).__name__) # tuple
print(len(d)) # 1(1)和(1,)只差一个逗号,类型却完全不同。空元组()是唯一的例外——它没有元素,不存在歧义。这个规则还有一个现实后果:函数返回多个值时返回的其实就是元组,return 1, 2和return (1, 2)等价。
三、不可变带来的可哈希
不可变让元组可以参与哈希。规则是:元组内每个元素都可哈希时,元组整体可哈希。可哈希意味着它能当字典的键、能放进集合。
# 适用于 Python 3.8+
# 元素都可哈希,元组可哈希
key = (2026, 10, 7)
d = {key: "今天"}
print(d[(2026, 10, 7)]) # 今天
# 元素含列表,元组不可哈希
bad = (1, [2, 3])
try:
hash(bad)
except TypeError as e:
print("报错:", e) # unhashable type: 'list'第二段说明「元组可哈希」是有条件的。同理,(1, {"a": 1})也不可哈希,因为字典不可哈希。要表达「含可变内容的不可变记录」,需要把内层换成不可变对象,比如frozenset。
# 适用于 Python 3.8+
safe = (1, frozenset({2, 3}))
print(hash(safe) is not None) # True
print(len({safe, (1, frozenset({2, 3}))})) # 1,内容是同一个最后一行把两个内容相同的元组放进集合,因为哈希和相等都一致,集合里只剩一个元素。
四、元组当作记录
工程上元组最常见的用法是表达「一条固定记录」。列表强调「同类元素的集合」,元组强调「几个不同字段绑在一起」。
# 适用于 Python 3.8+
# 数据库查询结果、坐标、日期区间,都适合用元组
point = (3, 5)
rect = (0, 0, 100, 200) # 左上角 x, y 和宽高
date_range = (20260101, 20261231)
# 解包让字段名在读取时变得清晰
x, y = point
left, top, width, height = rect
print(x, y) # 3 5
print(left + width, top + height) # 100 200
# 函数返回多个值就是返回元组
def divmod_like(a, b):
return a // b, a % b
quotient, remainder = divmod_like(17, 5)
print(quotient, remainder) # 3 2元组的比较是逐元素的,这让它成为多级排序的天然键。
# 适用于 Python 3.8+
records = [("bob", 90), ("alice", 90), ("carol", 85)]
print(sorted(records))
# 先比姓名,再比分数
print(sorted(records, key=lambda r: r[1], reverse=True))sorted(records)直接按元组逐元素比较:先比"bob"和"alice",所以结果是[('alice', 90), ('bob', 90), ('carol', 85)]。第二句指定key只比分数并降序。
五、具名元组:给字段起名字
字段一多,record[0]、record[1]这种写法就难读了。collections.namedtuple提供了带字段名的元组,它仍然是元组,仍然不可变、可哈希,但可以用属性名访问字段。
它的工厂函数签名包含typename、field_names,以及只能关键字传递的rename、defaults、module。defaults参数和_field_defaults属性需要Python 3.7 及以上。
# 适用于 Python 3.8+
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(11, 22)
print(p.x, p.y) # 11 22
print(p[0]) # 11,仍然可以按下标访问
print(p) # Point(x=11, y=22)
print(p._fields) # ('x', 'y')
# _replace 返回「新」对象,原对象不变
q = p._replace(x=33)
print(q) # Point(x=33, y=22)
print(p) # Point(x=11, y=22),p 没变
# _asdict 返回普通字典
print(p._asdict()) # {'x': 11, 'y': 22}三个下划线开头的成员要记住:
| 成员 | 类型 | 作用 | 是否原地修改 |
|---|
_make(iterable) | 类方法 | 从序列构造实例 | — |
_asdict() | 实例方法 | 转成字典 | 否,返回新字典 |
_replace(**kwargs) | 实例方法 | 替换部分字段 | 否,返回新实例 |
_fields | 属性 | 字段名元组 | — |
_field_defaults | 属性 | 字段默认值字典(3.7+) | — |
_replace返回新对象这一点特别容易踩坑:很多人写了p._replace(x=33)就以为p被改了,其实什么都没发生。
如果要类型标注,typing.NamedTuple提供类语法(3.6 及以上):
# 适用于 Python 3.8+
from typing import NamedTuple
class Point(NamedTuple):
x: int
y: int = 0 # 类语法里直接写默认值
p = Point(3)
print(p.x, p.y) # 3 0
print(p) # Point(x=3, y=0)
print(p._field_defaults) # {'y': 0}typing.NamedTuple在 3.6 起支持类语法,用注解声明字段,默认值直接写在字段上。它生成的对象和collections.namedtuple生成的是一样的元组子类,两个都能用_asdict、_replace、_fields。
六、实战:用元组与具名元组处理订单
# 适用于 Python 3.8+
from collections import namedtuple
Order = namedtuple("Order", ["order_id", "sku", "qty"], defaults=[1])
def total_qty(orders, sku):
"""统计某个 SKU 的总数量。默认值参数让它能只传两个字段。"""
return sum(o.qty for o in orders if o.sku == sku)
def top_sku(orders):
"""找出数量最多的 SKU,返回 (sku, 数量)。空的返回 None。"""
if not orders:
return None
totals = {}
for o in orders:
totals[o.sku] = totals.get(o.sku, 0) + o.qty
sku, qty = max(totals.items(), key=lambda kv: kv[1])
return sku, qty
if __name__ == "__main__":
orders = [
Order(1, "A100", 3),
Order(2, "B200", 1),
Order(3, "A100"), # 省掉 qty,用默认值 1
]
print(total_qty(orders, "A100")) # 4
print(top_sku(orders)) # ('A100', 4)
print(top_sku([])) # None逐行推演:Order用defaults=[1]给最后一个字段qty一个默认值,所以Order(3, "A100")也能构造,qty自动为 1。total_qty用生成器表达式求和,A100的两条记录分别是 3 和 1,合计 4。top_sku先用字典累加每个 SKU 的数量,再用max(..., key=...)取最大值那一项,解包成sku, qty,返回一个元组。空列表时提前返回None,避免max抛异常。
常见坑点
坑点 1:单元素元组漏逗号。❌t = (1),之后t[0]抛TypeError: 'int' object is not subscriptable。 ✅ 写t = (1,)。
坑点 2:以为元组可哈希,忽略了内部元素。❌d = {(1, [2]): "x"}——内层列表不可哈希,整个元组不可哈希,抛TypeError。 ✅ 保证元组内所有元素可哈希:d = {(1, (2,)): "x"}。
坑点 3:认为_replace会改原对象。❌p._replace(x=1)之后继续用p,以为p已更新——_replace返回新实例,p不变。 ✅ 接住返回值:p = p._replace(x=1)。
坑点 4:把*rest当成元组。❌first, *rest = (1, 2, 3)后写rest[0]拿到2没问题,但以为rest是元组——它是列表[2, 3]。 ✅ 需要元组就rest = tuple(rest);通常直接用列表即可。
坑点 5:用+=修改元组后以为别名也变了。❌a = (1, 2); b = a; a += (3,)后以为b == (1, 2, 3)——b仍是(1, 2)。 ✅ 记住元组的+=是新建加重新绑定;要共享修改语义请改用列表。
坑点 6:以为元组和列表可以相等。❌(1, 2) == [1, 2]期待True——类型不同,结果是False。 ✅ 先转换再比:tuple([1, 2]) == (1, 2)得到True。
坑点 7:collections.namedtuple默认值用错版本。❌ 在 Python 3.6 上写namedtuple("P", ["x", "y"], defaults=[0])——defaults参数是3.7 才加入的,3.6 上抛TypeError。 ✅ 3.7 及以上用defaults(注意它只作用于最右边的字段);更早版本要么升级,要么在构造函数里自己处理缺省。
坑点 8:以为具名元组可以改字段。❌p = Point(1, 2); p.x = 9——具名元组仍是元组,字段赋值抛AttributeError。 ✅ 用p = p._replace(x=9)得到新实例。
总结
| 概念 | 说明 |
|---|
| 本质 | 不可变的有序序列 |
| 语法标志 | 逗号,不是圆括号;(1)是整数,(1,)是元组 |
| 可哈希条件 | 内部每个元素都可哈希 |
| 能做而列表不能 | 当字典键、放进集合 |
| 专有方法 | count(value, /)、index(value[, start[, stop]]) |
| 具名元组 | collections.namedtuple(工厂)与typing.NamedTuple(类语法,3.6+) |
_replace | 返回新实例,不改原对象 |
理解元组的关键是顺着「不可变 → 可哈希 → 能当键和集合元素 → 适合表达固定记录」这条链条走一遍。当一段数据从创建到销毁都不该变化时,用元组既表达了意图,也拿到了可哈希这个额外能力。