# 元组

Source: https://codewiki.com/zh/python/tuples/

> - **what**: 元组（tuple）是有顺序、不可重新赋值的序列，适合表达位置和数量固定的数据。
> - **trap**: 元组只固定元素引用，并不会冻结元素指向的可变对象；包含列表的元组也不能作为字典键。
> - **fix**: 单元素元组要保留尾随逗号，解包时要明确数据形状；字段含义重要时使用 `NamedTuple` 或数据类。

## 是什么，为什么存在

元组是 Python 内置的不可变序列。它保留元素顺序，支持索引、切片、迭代和成员检查，但不提供按位置赋值、追加或删除操作。元组通常写成 `(region, count)`，不过真正构成元组的是逗号，而不是圆括号。

元组解决的是“把固定数量、固定位置的值作为一个整体传递”的问题。坐标、字典复合键、函数的多返回值，以及不应增删字段的短记录都适合使用元组。它向读者表达的是稳定的数据形状，而不是一份准备继续扩展的集合。

列表适合数量会变化的同类元素；元组适合位置具有约定含义的固定结构。这个区别并不表示元组中的对象一定不可变，也不表示元组天然适合所有只读数据。字段较多或调用方需要记住 `record[3]` 的含义时，位置约定已经不够清楚。

命名元组（named tuple）保留元组的索引、迭代和解包行为，同时为每个位置提供字段名。现代类型化代码通常用 `typing.NamedTuple` 声明这种记录；动态生成字段的场景仍可使用 `collections.namedtuple()`。命名元组是元组的扩展，而不是运行时数据验证器。

## 工作原理

### 逗号创建元组

表达式列表中出现逗号时，Python 会把各项打包成元组。圆括号经常只是为了分组或提高可读性，所以 `(42)` 是整数 `42`，而 `(42,)` 才是单元素元组。空元组没有可供逗号分隔的元素，因此必须写成 `()`。

这种从多个表达式组成元组的过程叫作元组打包（tuple packing）。`point = 3, 4` 与 `point = (3, 4)` 得到相同的值。函数返回 `minimum, maximum` 时也会先构造一个二元素元组。

`tuple(iterable)` 则会消费一个可迭代对象并建立元组。`tuple("ab")` 得到 `("a", "b")`，而不是单元素元组 `("ab",)`。如果参数本身已经是元组，语言允许实现直接返回同一个对象；代码不应依赖一次多余复制来隔离内容。

### 构造器会消费输入

`tuple()` 的参数是可迭代对象，不是待放入唯一槽位的任意值。传入生成器或迭代器时，构造器会一直读取到迭代结束，再返回完整元组。原迭代器随后通常已经耗尽。

这种转换会固定外层元素序列，适合需要重复遍历或稳定长度的边界。它仍然只复制元素引用，不会递归复制元素。若输入在转换后继续修改，其中已存在的可变元素仍可能与元组共享。

Python 没有专用的“元组推导式”语法。`(transform(item) for item in source)` 是生成器表达式，需要写成 `tuple(transform(item) for item in source)` 才会立即建立元组。是否立即消费输入会改变异常出现的时机，也会改变无限迭代器能否使用。

一些内置迭代工具会逐项产出元组。`enumerate(values)` 产出索引和值组成的二元素元组，`zip(left, right)` 产出各输入对应位置组成的元组。循环目标中的解包直接消费这些项，通常无需先把整个迭代器转换为元组。

### 不可变的是元素槽位

创建后，元组的长度和各元素槽位指向的对象不能改变。`items[0] = value`、`append()` 和 `pop()` 都不是元组支持的更新方式。拼接、重复与切片会产生元组结果，而不是在原对象上修改。

这是一种浅层不可变性。若某个槽位指向列表，仍可通过该列表自己的接口修改内容；元组只是继续指向同一个列表。因而“元组不可变”不能推导出“从元组可达的整个对象图不可变”。

名称重新绑定也不是修改元组。执行 `route += ("checkout",)` 时，Python 计算一个新元组，再让名称 `route` 指向新对象；原元组仍保持不变。其他仍引用原元组的名称不会看到新元素。

### 序列操作与比较

元组使用通用序列协议。整数索引读取一个元素，切片读取一个新元组，`len()` 返回元素数量，`in` 按相等性查找元素。元组只额外提供 `count()` 与 `index()` 两个公开方法。

两个元组按字典序比较：先比较第一对不同的元素；若共同前缀都相等，则较短的元组在排序中靠前。参与比较的元素必须支持对应的比较操作。混入不能排序比较的类型可能在运行时抛出 `TypeError`，所以不要把任意异构元组当作天然排序键。

相等性也逐元素判断，并要求长度相同。`(1, 2) == (1, 2)` 为真，而 `(1, 2) == [1, 2]` 为假，因为序列类型不同。成员对象的相等规则仍会参与结果。

### 解包按形状绑定

序列解包（sequence unpacking）把右侧可迭代对象产生的值绑定到左侧目标。左侧没有星号目标时，值的数量必须精确匹配；过多或过少都会抛出 `ValueError`。这项语法适用于任何可迭代对象，不只适用于元组。

左侧最多可以有一个星号目标，例如 `first, *middle, last = values`。它会收集没有被其他目标消费的值，并始终得到一个列表，即使右侧是元组。嵌套目标会继续检查内层形状，因此 `name, (x, y) = record` 同时表达两层结构。

赋值前会先计算右侧，所以 `left, right = right, left` 能安全交换两个绑定。函数的多返回值也使用同一机制：函数返回一个元组，调用方再选择整体接收，或者按形状解包。公开 API 一旦改变返回元组的长度，所有固定长度解包的调用方都可能失效。

### 星号语法取决于上下文

星号在三个相近场景中都表示展开或收集，但结果并不相同。审查生成代码时，应先确定语法位置，再判断产生的是列表、元组还是函数实参。

- 在赋值目标 `head, *tail = values` 中，`tail` 收集为列表。
- 在元组显示 `(*left, *right)` 中，可迭代对象的各项被放入一个新元组。
- 在函数调用 `send(*values)` 中，各项成为独立的位置实参。

函数调用展开不会传递“一个元组参数”。若 `send()` 只声明一个形参，而 `values` 含三个元素，`send(*values)` 会尝试传入三个位置实参并触发签名检查。需要把元组本身作为一个实参时，应写 `send(values)`。

同理，星号赋值目标不保留右侧容器类型。即使输入是元组，收集部分也会成为列表。调用方若需要不可变结果，应在解包之后根据契约显式转换，而不是假定星号目标会继承输入类型。

### 哈希取决于所有元素

可哈希（hashable）对象可以作为字典键或集合成员。只有每个元素都可哈希时，元组才可哈希；元组外层不可变并不能弥补内部列表或字典不可哈希。`(region, year)` 通常适合作为复合键，`(region, tags_list)` 则不行。

这条规则保护哈希容器的查找不变量。键参与相等判断的数据不能在存放期间以破坏哈希一致性的方式变化。审查复合键时，应检查完整的嵌套结构，而不是看到最外层是元组就停止。

## 示例

### 元组语法与序列操作

第一个示例区分分组表达式、单元素元组和普通多元素元组，并展示不会修改原值的切片与拼接。

<!-- quick -->

```python
# file: tuple_basics.py
empty = ()
not_a_tuple = (42)
singleton = (42,)
route = ("home", "catalog", "product")

print(type(empty).__name__, len(empty))
print(type(not_a_tuple).__name__)
print(type(singleton).__name__, singleton)
print(route[0], route[-1])
print(route[1:])

extended = route + ("checkout",)
print(route)
print(extended)
print(route.count("catalog"), route.index("product"))
```

```text
tuple 0
int
tuple (42,)
home product
('catalog', 'product')
('home', 'catalog', 'product')
('home', 'catalog', 'product', 'checkout')
1 2
```

<!-- /quick -->

`extended` 是新元组，所以打印 `route` 时仍只有三个元素。`("checkout",)` 中的逗号不可省略；如果写成 `("checkout")`，右侧就是字符串，不能与元组拼接。

索引返回槽位中的对象，切片则返回元组。`count()` 统计相等元素的数量，`index()` 返回第一个相等元素的位置；找不到元素时，`index()` 会抛出 `ValueError`。

### 返回值与嵌套解包

下一个示例让函数返回概要元组。调用方先按三个位置解包，再把中间若干项收集到星号目标中。

```python
# file: unpack_orders.py
def summarize_orders(amounts):
    total = sum(amounts)
    return len(amounts), total, total / len(amounts)


count, total, average = summarize_orders((18, 24, 30))
print(f"count={count} total={total} average={average:.1f}")

shipment = ("PKG-204", (48.86, 2.35), "packed", "priority")
tracking_id, (latitude, longitude), *labels = shipment

print(tracking_id)
print(f"{latitude:.2f}, {longitude:.2f}")
print(labels, type(labels).__name__)

left, right = "cold", "hot"
left, right = right, left
print(left, right)
```

```text
count=3 total=72 average=24.0
PKG-204
48.86, 2.35
['packed', 'priority'] list
hot cold
```

`summarize_orders()` 实际返回一个三元素元组。固定长度解包把返回结构写进调用代码，因此函数与调用方应共同测试空输入和返回形状；这个示例只接受非空数据。

嵌套目标 `(latitude, longitude)` 检查坐标恰好包含两个值。`labels` 使用星号目标，所以结果是列表。最后的交换先求出右侧两个对象，再更新左侧名称，不需要临时变量。

### 复合字典键

元组经常把多个独立维度组成一个字典键。示例同时验证了外层元组并不能让内部列表变得可哈希。

```python
# file: coordinate_index.py
temperatures = {
    ("Paris", 9): 19.5,
    ("Paris", 10): 21.0,
    ("Lyon", 9): 18.0,
}

city_hour = ("Paris", 10)
print(temperatures[city_hour])
print(("Lyon", 10) in temperatures)

candidate_key = ("Paris", [9, 10])
try:
    temperatures[candidate_key] = 20.0
except TypeError as error:
    print(type(error).__name__, str(error))

stable_key = ("Paris", (9, 10))
temperatures[stable_key] = 20.0
print(temperatures[stable_key])
```

```text
21.0
False
TypeError cannot use 'tuple' as a dict key (unhashable type: 'list')
20.0
```

`("Paris", 10)` 的两个元素都可哈希，因此可以稳定地参与字典查找。候选键含有列表，计算整个元组的哈希时会在该元素处失败。把时间范围也改为元组后，这个具体结构中的所有元素都可哈希。

把列表转换成元组只是建立快照，不会自动规范化领域数据。若键的大小写、时区或数值单位可能不同，应先定义规范化规则，再构造复合键。

### 用 `NamedTuple` 表达字段

位置记录增长到三个以上字段时，属性名通常比裸索引更容易审查。`NamedTuple` 仍是元组，因此可以解包、作为键，并用 `_replace()` 创建带有字段变更的新值。

```python
# file: shipment_record.py
from typing import NamedTuple


class Shipment(NamedTuple):
    tracking_id: str
    status: str
    checkpoints: tuple[str, ...] = ()

    def advance(self, place: str) -> "Shipment":
        return self._replace(
            status="in_transit",
            checkpoints=(*self.checkpoints, place),
        )


shipment = Shipment("PKG-204", "packed")
moved = shipment.advance("Paris")

print(shipment)
print(moved.status)
print(moved.checkpoints)
print(moved[0])
tracking_id, status, checkpoints = moved
print(tracking_id, status, len(checkpoints))
```

```text
Shipment(tracking_id='PKG-204', status='packed', checkpoints=())
in_transit
('Paris',)
PKG-204
PKG-204 in_transit 1
```

`advance()` 没有修改原记录，而是返回 `_replace()` 构造的新实例。`checkpoints` 也使用元组，避免调用方通过列表方法修改这个字段。输出仍能看到索引访问和解包能力，但业务代码应优先使用字段名。

类型注解帮助静态检查器发现错误，却不会让构造函数在运行时拒绝所有类型不匹配的值。来自 JSON、数据库或命令行的输入仍要在边界处验证。需要运行时校验、可变字段、关键字专用初始化或复杂继承时，数据类或验证模型通常更合适。

## 陷阱

### 圆括号不等于单元素元组

> **陷阱:** `(value)` 只是带分组括号的表达式。生成代码常把 `tuple(value)` 当成单元素包装；当 `value` 是字符串或其他可迭代对象时，它反而会拆成多个元素。

**修复方法：** 使用 `(value,)`，并测试空字符串、普通字符串和已经是元组的输入。格式化多行元组时保留每项后的尾随逗号，这样增删行时不易改变语法含义。

### 不可变性不会递归

> **陷阱:** 元组不能替换元素槽位，但槽位中的列表、集合或自定义对象仍可能变化。把含列表的元组作为“不可变快照”返回，会让调用方修改到共享状态。

**修复方法：** 根据所有权要求把嵌套集合转换为不可变表示，或复制需要隔离的可变对象。不要只检查最外层容器；测试应在返回后尝试修改嵌套值，并观察原始状态。

### 元组不一定可哈希

> **陷阱:** 看到元组就把它作为字典键，会在任一元素不可哈希时抛出 `TypeError`。更隐蔽的风险是自定义元素虽然提供哈希，却允许参与相等与哈希的状态发生变化。

**修复方法：** 验证复合键每一层的哈希契约，并优先使用语义稳定的标量或不可变值。不要为了消除异常而对可变值调用 `str()`；不稳定或含歧义的字符串表示会制造错误的键。

### 固定长度解包会暴露返回形状

> **陷阱:** 调用方写下 `value, error = parse()` 后，就依赖返回对象恰好产生两个值。生成代码有时在某个分支多返回诊断信息，或者用 `None` 代替元组，使错误只在该分支触发。

**修复方法：** 让所有分支返回同一形状，并为成功、空输入和错误分支分别测试。公开记录需要演进时，使用带字段名的对象通常比继续扩展位置元组更安全。

### `NamedTuple` 注解不做运行时校验

> **陷阱:** `Shipment(123, [], "Paris")` 之类的调用不会因为注解自动完成领域校验。错误类型可能一直传播到哈希、序列操作或序列化阶段才暴露。

**修复方法：** 把 `NamedTuple` 当成静态类型提示与记录表示，在不可信输入边界显式解析和校验。如果必须在构造时强制不变量，选择能执行验证的类或模型，并为拒绝路径编写测试。

### 可变默认字段会被实例共享

> **陷阱:** 在 `NamedTuple` 类体中写 `tags: list[str] = []`，会让省略该字段的实例复用同一个列表。记录外层不可变，但通过任一实例修改列表后，其他实例也会看到变化。

**修复方法：** 只使用真正适合共享的不可变默认值，例如 `tags: tuple[str, ...] = ()`。每个实例都需要新可变对象或默认工厂时，改用支持 `default_factory` 的数据类，并测试两个默认构造的实例是否隔离。

<!-- deep -->

## 元组边界与对象标识

元组保存对象引用。读取 `values[0]` 得到的是该槽位引用的对象，不是语言自动创建的副本。两个不同元组可以引用同一个可变对象，因此通过其中一个元组取得对象并修改后，从另一个元组也能观察到变化。

元组自身的对象标识与值相等是两件事。独立构造的 `(1, 2)` 可以比较相等，却不必是同一个对象。解释代码时应使用 `==` 描述值相等，仅在确实关心同一个对象时使用 `is`；编译器常量复用等实现细节不能作为应用逻辑。

切片、拼接和重复只规定结果的值与类型，不承诺递归复制元素。特别是 `([0],) * 3` 会重复同一个列表引用，而不是创建三个独立列表。需要独立嵌套对象时，应在每次迭代中显式构造对象。

名称上的 `+=` 会先尝试原地加法协议，再在元组不支持原地修改时得到拼接结果并重新绑定名称。观察到名称对应的值变长，并不代表原元组被修改。判断共享状态问题时，应分别跟踪容器对象、元素对象和名称绑定。

## 哈希与相等性的递归条件

元组的相等判断依次委托给对应元素。哈希也必须组合元素的哈希，因此任何不可哈希元素都会让整个元组不可哈希。这是一项递归条件：内层元组只有在其所有元素也满足条件时，才能帮助外层元组成为键。

可哈希不等于“语法上不可变”。自定义类可能按对象标识保留默认哈希，即使它有可变属性；另一些不可变风格的类也可能因为定义了相等而主动禁用哈希。选择字典键应依据对象的相等与哈希契约，而不是依据类名或表面语法猜测。

复合键还把数据规范化规则变成接口的一部分。`("Paris", 9)`、`("paris", 9)` 和 `("Paris", "09")` 都是不同的元组值。若领域认为它们等价，应在构造键之前统一大小写和类型，并在读写两条路径使用同一个规范化函数。

## 普通元组、命名元组与数据类

选择记录形式时，先看调用方需要什么接口。普通元组适合非常短、位置含义已由局部上下文清楚表达的数据。只要某个位置需要注释才能解释，字段名通常就能减少审查成本。

| 形式 | 适合的契约 | 主要限制 |
| --- | --- | --- |
| 普通元组 | 短小、固定、按位置使用的序列 | 字段含义不随值一起出现 |
| `NamedTuple` | 仍需索引和解包的只读记录 | 注解不执行运行时校验，形状演进会影响解包方 |
| 数据类 | 需要清晰字段、定制初始化或可变策略的领域对象 | 默认不具有元组的索引和解包接口 |

`NamedTuple` 类是 `tuple` 的子类，实例仍可使用位置操作。`_fields` 公开字段名，`_asdict()` 提供字段映射，`_replace()` 返回替换指定字段后的新实例。这些以下划线开头的名称是命名元组文档化接口的一部分，但业务封装仍可提供更有领域含义的方法。

保持元组兼容性也意味着位置顺序属于契约。在命名元组中间插入字段，会改变索引、迭代和解包结果，即使属性访问代码看起来未受影响。公共模型会长期演进、需要关键字专用参数或运行时验证时，不应仅为了“轻量”而强行保留元组协议。

## API 边界上的元组形状

函数返回元组时，元素数量、顺序和含义共同形成返回契约。类型注解 `tuple[int, str]` 可以描述固定形状，`tuple[str, ...]` 则描述任意长度的同类元素。两者都不会在运行时自动检查实际返回值。

调用方对结果的使用方式决定变更风险。整体转发元组的代码可能容忍新增尾部元素，固定长度解包会立即失败，而用数字索引读取旧位置的代码可能继续运行却忽略新信息。后两种行为都需要兼容性测试，不能只检查函数自身。

| 返回值变更 | 固定解包的影响 | 索引读取的影响 |
| --- | --- | --- |
| 在末尾新增元素 | 抛出 `ValueError` | 旧索引通常仍指向原字段 |
| 调换两个元素 | 仍能运行，但绑定含义改变 | 仍能运行，但字段含义改变 |
| 某分支返回 `None` | 抛出 `TypeError` | 读取时抛出 `TypeError` |

顺序变化最危险，因为代码可能继续运行并产生语义错误。为返回元组编写测试时，应断言完整结果或使用带领域名称的变量，而不是只断言元素数量。跨模块的返回记录一旦有多个长期使用方，命名字段通常更容易演进与审查。

把函数实参收集为 `*args` 时，函数体收到一个元组。这个元组表示一次调用提供的位置实参序列，并不证明其中的值类型相同。转发为 `target(*args)` 会重新展开它，因此包装器还必须正确处理关键字实参、签名约束和错误传播。

在公开 API 中，普通元组最适合稳定且短小的形状。若调用方需要可选字段、版本兼容策略或运行时验证，应选择能明确表达这些规则的记录类型。容器形式是接口决策，不只是实现细节。

针对元组 API 的兼容性审查应回答四个问题：

- 每个位置是否有唯一、稳定的领域含义？
- 所有返回分支是否提供相同的元素数量与顺序？
- 调用方是整体转发、数字索引，还是固定长度解包？
- 新字段应扩展位置契约，还是改用具名记录？

<!-- /deep -->

[检查点: python/tuples](https://codewiki.com/zh/python/tuples/#checkpoint)

## 延伸阅读

- [Python 标准库：元组类型](https://docs.python.org/3.14/library/stdtypes.html#tuple)
- [Python 语言参考：表达式列表](https://docs.python.org/3.14/reference/expressions.html#expression-lists)
- [Python 教程：元组与序列](https://docs.python.org/3.14/tutorial/datastructures.html#tuples-and-sequences)
- [Python `typing.NamedTuple`](https://docs.python.org/3.14/library/typing.html#typing.NamedTuple)
