AI 技术博客
返回首页
Python 基础 17 分钟阅读

元组:不可变序列与解包的艺术

Python元组解包不可变

引言

上一篇拿到了列表这把「可变的瑞士军刀」,本文介绍它的孪生兄弟——元组(tuple)。语法上元组几乎就是「不能改的列表」,但删除、排序这些操作的缺席并不是残缺,而是换来了两样列表给不了的东西:可哈希(给字典当键,下篇的主角)与可打包解包(一次给多个变量赋值、函数返回多个值)。Python 里大量「一口气返回/接收多个东西」的惯用法都建立在元组之上,这是本篇的主题。

先决知识:上篇的全部内容,以及第五章第 2 篇的切片规则(元组切片与列表完全一致,返回新元组)。本篇为后续铺路的地方有三处:可哈希性将在下篇讲字典键时正式定义;「元组存的是引用、不可变是浅层」将在第 5 篇讲拷贝时彻底解剖;collection 模块的具名元组(namedtuple)本篇只预告,第 7 篇详述。

概念与原理

元组:只读的引用数组

元组的本质与列表相同——底层都是对象指针数组,所以索引 O(1)、切片 O(k)、成员测试 O(n) 这些复杂度结论原样成立。区别只有一个:元组创建后,数组里的每个槽不能再指向别的对象。不能 t[0] = x(元组没有这个入口),不能 append、extend、insert、remove、pop、clear、sort、reverse——凡是会改数组内容的方法,元组全都没有。注意措辞:不能改的是「槽」,槽里指向的对象如果本身是可变对象(如列表),仍然可以修改——「不可变」是浅层的,本篇陷阱二会实测演示,完整原理留给第 5 篇。

为什么需要不可变数据

「反正不能改,为什么专门造一个类型?」这是理解元组的第一个问题,答案有三个层次。

第一,安全。函数把元组传出后,调用方无论如何都无法篡改它,「数据的完整性」由类型本身保证,而不是靠自觉。想象一个返回坐标、配置项、错误码的函数,如果返回列表,任何下游代码都能 result[0] = 恶意值 而不触发任何警告;返回元组,这种事故在类型层面就被否决了。多线程环境下,不可变对象还可以安全地共享(改不了,就不存在竞争窗口)。

第二,正是「不能改」让它能进哈希(hash)运算。哈希函数要求:对象的哈希值在其生命周期内恒定不变。可变对象的值一变,哈希值就该跟着变,但字典/集合正是靠哈希值定位的——值变了位置还在老地方,查找就错乱,所以可变对象一律不可哈希。整数的哈希就是它自身(可以随时再算),字符串、元组这类不可变对象哈希值恒定,可以当字典键。下篇讲字典的「可哈希」要求时,元组就是键的第一候选。

第三,语义信号。程序员看到 (x, y) 会读作「这是一个打包好的固定结构」,看到 [x, y] 会读作「这是一个待处理的数据清单」。代码即文档,类型选择本身就是信息。Python 社区约定:结构用元组,批量数据用列表。

单元素元组:逗号才是元组的灵魂

元组字面量常见写法是圆括号 (1, 2, 3),但塑造元组的不是括号而是逗号。(42) 在 Python 里只是「带括号的整数 42」,因为括号的常规用途是分组;写成 42, 或 (42,) 才是单元素元组。同理,1, 2, 3 不写括号也是一个三元组——函数返回 return 1, 2 丢进一个元组,就是靠这个机制。记住一句话:没有逗号,就没有元组。

内存账本:元组比列表便宜在哪里

元组没有 append、insert 这类扩容需求,创建时一次分配精确大小的数组,无需预留冗余。实测 64 位 CPython 3.12:4 个整数的元组占 72 字节(24 字节头 + 4 个 8 字节槽),同内容的列表占 88 字节(56 字节头 + 同样 4 个槽 + 预留的 16 字节增长空间);3 个字符串的元组 64 字节,列表 88 字节。也就是说列表为「可增长」预支了约 24 字节的头与缓冲。此外 CPython 对长度不超过 20 的小元组有缓存池(free list)优化,回收后复用,减少分配次数——这是实现层面的细节,规范不承诺,跨实现(PyPy 等)不要依赖。速度上两者遍历几乎无差,元组因缓存与无扩缩容略省心,但量级差别很小——选元组首要理由仍是安全、哈希与语义,性能是锦上添花。

操作与实现

读操作全家桶:与列表一致,少了一堆写操作

point = (3, 5)
print(point[0], point[1])
print(len(point))
print(3 in point)
print(point[:1])
try:
    point[0] = 99
except TypeError as err:
    print(err)
try:
    point.append(4)
except AttributeError as err:
    print(err)
3 5
2
True
(3,)
'tuple' object does not support item assignment
'tuple' object has no attribute 'append'

索引、负索引、长度、成员测试、切片、拼接与重复——上篇序列操作五件套原样可用,point[:1] 返回单元素元组 (3,)(注意显示时带逗号,正是单元素元组的标准形态)。两条报错是元组的身份证:'tuple' object does not support item assignment 与 'tuple' object has no attribute 'append'——前者说明槽不能改,后者说明连改的入口都没有。查询方法 count 与 index 元组也有:

skills = ("python", "sql", "python", "go")
print(skills.count("python"))
print(skills.index("go"))
2
3

单元素元组:逗号决定一切

single = (42,)
print(type(single))
no_comma = (42)
print(type(no_comma))
<class 'tuple'>
<class 'int'>

同一对括号,多一个逗号是元组,少一个就是整数——这是元组第一课,也是第一坑。构造空元组用 (),单元素必须 (x,),两个以上元素随意。工程上「函数返回一个值时别写 return x,」这类意外不常见,常见的是初始化 datetime、坐标这类场景里漏写逗号,得到整数后报出诡异的 TypeError。

解包:一次语句,多个变量

元组最常用的不是读取,而是解包(unpacking / destructuring):把可迭代对象的元素按位置逐一取给等号左侧的变量。左侧有几个名字,右侧就有几个元素,数量必须严丝合缝:

city, lat, lon = "北京", 39.90, 116.40
print(city, lat, lon)
a, b = 1, 2
b, a = a, b
print(a, b)
score = 88, 95, 79
print(score)
北京 39.9 116.4
2 1
(88, 95, 79)

第三行藏的技巧值得单独讲:b, a = a, b 是交换变量的标准写法。右侧 (a, b) 先整体打包成元组,左侧再整体解包——求值先于赋值,整个过程无临时变量、无覆盖风险,其他语言要三行加中间变量,Python 一行搞定。它等价于 (b, a) = (a, b) 的显式形式。

函数多返回值:打包与解包的分工

函数只能返回一个对象,但那个对象可以是一个元组——「返回多个值」在 Python 里就是「返回一个元组,调用方顺手解包」:

def parse_user(line):
    parts = line.split(",")
    return parts[0], int(parts[1])

name, age = parse_user("zhang,23")
print(name, age)
_, level = parse_user("li,30")
print(level)
zhang 23
30

return parts[0], int(parts[1]) 没有括号,靠逗号打包成元组;name, age = ... 解包接收。第二个调用示范了下划线惯例:_ 用来吸收「不要的值」,只取年龄。collection 模块的具名元组(namedtuple)在下一篇之后详细讲,它让「函数返回的元组」还能按名字取值(result.name),比裸下标可读得多。

星号解包:把「剩下的」收进一个列表

数量不固定时,Python 3 的扩展解包(extended unpacking)用 * 命名「收下其余部分」,星号变量只能出现一次,且会收成一个列表:

first, *middle, last = [10, 20, 30, 40, 50]
print(first, middle, last)
head, *rest = "python"
print(head, rest)
total, *scores = (88, 95, 79)
print(total, scores)
10 [20, 30, 40] 50
p ['y', 't', 'h', 'o', 'n']
88 [95, 79]

三种形态全覆盖:first, *middle, last 取首尾、把中间一网打尽;head, *rest 取头、收尾——注意对字符串解包得到的是字符,rest 是列表 ['y', 't', 'h', 'o', 'n'](星号收的是列表不是元组);total, *scores 取首项收后面。若想让星号在任意位置,它必须在左侧唯一:a, *b, *c = ... 是编译期 SyntaxError(语法错误在编译阶段就被拒绝,运行时 try/except 拦不住),想分层拆,用嵌套解包:

data = ("zhang", (1.75, 68.0))
name, (height, weight) = data
print(name, height, weight)
zhang 1.75 68.0

内层元组 (1.75, 68.0) 被第二层括号再次解包成两个变量——「嵌套数据结构配嵌套解包」,一一对应,这是解析树、JSON 这类分层数据的标准姿势。解包左侧如果多一个或少一个变量,运行时抛 ValueError: too many values to unpack 或 not enough values(陷阱一详述)。

不可变的边界:引用不能换,对象可以改

shallow = (1, [2, 3])
shallow[1].append(4)
print(shallow)
positions = {(1, 2): "基地", (3, 4): "仓库"}
print(positions[(1, 2)])
try:
    hash([1, 2])
except TypeError as err:
    print(err)
(1, [2, 3, 4])
基地
unhashable type: 'list'

shallow[1].append(4) 成功执行:元组槽仍指向原列表,但列表内容被改成了 [2, 3, 4],元组「看起来变了」——这就是浅层不可变的全部真相:元组保证槽位不变,不保证槽内对象不变。凡是要把元组当字典键、当集合元素,元组里就不能装可变对象,否则哈希值会随内容漂移——hash((1, [2])) 直接抛 unhashable type: 'list',Python 在构造时就把危险挡在门外(嵌套元组的哈希会递归计算,内层不可哈希整体就不可哈希)。{(1, 2): "基地"} 这是字典的预告:元组可哈希、能当键;下篇正式讲键的要求。

具名元组预告:namedtuple

元组下标访问 p[0] 可读性差,namedtuple 给元组字段命名:

from collections import namedtuple
Point = namedtuple("Point", "x y")
p = Point(3, 5)
print(p)
print(p.x, p.y)
print(p[0])
Point(x=3, y=5)
3 5
3

namedtuple 返回的是一个元组子类:依然不可变、依然可解包、依然可哈希,但多了字段名访问(p.x)与漂亮的自描述输出(Point(x=3, y=5))。它是「结构用元组」思路的升级版,具体设计与应用在第 7 篇与 collections 模块专题展开。

重绑定与 +=:新对象,不是修改

t = (1, 2)
t += (3,)
print(t)
(1, 2, 3)

t += (3,) 看起来「往元组里加了东西」,实际是「算出一个新元组 (1, 2, 3),再让变量 t 改指它」——元组从未被修改,是 t 被重新绑定(rebinding)了。这与列表的 += 不同:列表会原地扩展。判断方法:看对象身份变化。id(t) 在元组自增前后不同、列表则相同。同样,函数内对元组参数做 params += (...) 也不会像列表那样污染调用方的对象,因为新对象只换了自己的绑定——不可变数据的另一层安全。

易错点与陷阱

陷阱一:漏写逗号的单元素元组;解包数量不匹配

(42,) 是元组、(42) 是整数——返回「单个元素的元组」时漏逗号,函数的返回值类型直接从 tuple 变成 int,下游解包立刻 TypeError: cannot unpack non-iterable int object。数量不匹配则是运行时 ValueError:a, b = (1, 2, 3) 报 too many values to unpack (expected 2),a, b, c = (1, 2) 报 not enough values to unpack (expected 3, got 2)。修法统一:听到「unpack」的报错,先数左右两侧的元素个数;拿不准长度时改用星号吸收多余项,或先 len() 断言。

陷阱二:以为元组不可变就万事大吉

元组的不可变性是槽位级的:t = ([1], 2) 中 t[0].append(9) 合法,元组内容随之变化。凡是需要「真正不可变」的场合(字典键、集合元素、跨线程共享的配置),元组内只能装不可变对象;要彻底冻结任意结构,需要第 5 篇的深拷贝(deepcopy)配合。这条陷阱的另一面:面试与作业里「元组不可变」常被问成「元组完全不可变」,答案都要落在这条深浅分界线上。

陷阱三:把解包变量名与「修改原对象」混为一谈

a, b = b, a 交换的是变量所指的对象,要求两侧都是「可解包的活对象」;对不可变对象做 a, b = a, b 之后被改名的是绑定关系,原对象纹丝不动。另一个衍生坑:在循环里用 for k, v in d.items() 解包字典项,改 k/v 不会改回字典——解包是「复制引用」,不是「引用原对象」。想改字典里的值,得直接写 d[k] = ...。

陷阱四:星号解包的三个边界

第一,* 变量只能有一个(a, *b, *c = x 是 SyntaxError,编译期报错);第二,星号接收的永远是列表——first, *rest = "python" 的 rest 是 ['y', 't', 'h', 'o', 'n'] 而不是元组;第三,星号在解包左侧才叫解包,写 x, y = *a, *b 是合法的(右侧星号展开),而 *a = ... 之类的右侧星号用于函数调用参数展开(第四章 *args 讲过),两者别混。还有一个空容器边界:first, *rest = [] 因第一个目标没有值可拿抛 ValueError: not enough values to unpack (expected at least 1, got 0)——先判空再解包。

小结

元组是「只读版列表」:存储机制相同,序列操作(索引、切片、成员测试、count/index)完全相同,但槽位不可改——换来可哈希(当字典键)、安全(防篡改)与语义信号(结构 vs 数据)。元组由逗号定义:(42,) 是元组、(42) 是整数;解包 a, b = ... 一次给多变量赋值,b, a = a, b 完成交换,函数 return 1, 2 打包、调用方解包接收;*rest 扩展解包收「剩余项」进列表,嵌套解包应对分层数据;+= 于元组是重绑定而非原地修改。陷阱集中在:漏逗号、数量不匹配的 ValueError、浅层不可重新绑定、星号三边界与「解包不写回原对象」。下篇上场的主角是字典——键值对建模、增删查改与遍历,下篇会解释「为什么字典键必须不可变」,元组与字符串就是键的主力军。

练习与思考题

  1. 不运行代码,写出结果并验证:type((1,))、type(())、a, b = (1,)(结果是什么)、(1, 2) + (3,)、(1, 2) * 2。
  2. 用一行代码交换三个变量 x, y, z = 1, 2, 3 使它们变成循环轮转(x→y→z→x),提示:写成 x, y, z = z, x, y 验证效果。
  3. 写一个函数 stat(seq) 返回 (最小值, 最大值, 平均值),要求三值打包返回;再在调用方用解包把三个值取出来,并用 round 控制平均值保留两位小数(允许使用任何已学内置函数)。
  4. 思考题:为什么 Python 选择「元组不可变 → 可哈希」,而不是像某些语言那样给列表一个「哈希等于内容」的规则?提示:先考虑列表内容改变后,之前放入字典的键应该怎么办,再想想 CPython 里字典是如何用哈希定位存储位置的。