
1. 先聊清楚元组到底是个什么有没有一种数据结构长得跟列表很像却倔强得不让改有它就是元组tuple。为什么要学它学完你能把代码写得既安全又优雅还能顺便搞清楚为什么有些Bug查了半天元凶居然是自己把不该改的数据改了。我在带新人和给学生上课时经常看到有人把函数里的临时数据一股脑塞进列表返回之后再被调用方改来改去。排查到最后往往发现不是数据本身算错了而是数据“被别人改坏了”。这时候我会说这种数据一开始就该用元组。列表是用来“装”的元组是用来“守”的。这期趣玩Python第八期标题虽然就俩字但元组在Python里撑起的东西一点都不少多值返回、并行赋值、字典键、命名元组、函数参数……几乎都离不开它。下面的代码我都给了完整示例保证你能复制到本地Python环境里直接跑。如果你是第一次接触元组建议从头看如果你已经写了几个月Python也可以直接跳到第3节和第4节那里埋了不少新手容易翻车的细节。1.1 从“改不了的列表”这个比喻说起很多教程喜欢把元组叫“不可变列表”。这个比喻对入门确实友好但我觉得它只讲对了一半。元组确实和列表一样是线性序列支持索引、切片、遍历、求长度切片出来的结果照样是元组。可一旦你想往里面塞新元素或者把某个位置的值替换掉它立刻翻脸TypeError根本不给你商量的余地。但另一半更重要。列表只能当容器而元组还能当“契约”。一个函数返回(x, y)坐标调用方拿到后就不该随意去改它一个配置文件里的只读参数用元组存也比用列表存更能表达“这里不希望你动”。所以别只记“改不了”还要记“我不会被别人改也不会误改别人”。这就是不可变类型在工程里的价值把出错的可能直接在语法层面堵死。1.2 元组的两个身份容器与数据契约往小了说元组是一个装任意类型元素的小盒子(apple, 3, True) 完全合法。往大了说它把一组“本来就有紧密语义”的数据绑在一起。比如一个人有姓名和年龄用(张三, 25)表达比分开两个变量再手动关联要清晰得多。这种绑定的能力正好支撑了Python函数的多值返回return name, age 看着像返回两个值运行时的本质其实是返回了一个元组。再说白一点元组在代码里出现的场景不外乎两类一类是“我只是顺路把几个数据放一起”另一类是“你拿到这个东西请你别改”。写代码的时候如果能分清这两种心态你自然就知道该选元组还是选列表了。可惜很多人只记住了语法没记住使用场景结果元组用成了“一个不能变的列表”却完全没发挥它真正的价值。1.3 什么时候用元组什么时候用列表我总结了几个简单的判断标准做练习和做项目都适用判断角度用元组用列表数据是否允许修改不允许恒定不变允许增删改是否要作为字典键或集合元素可以前提是里面套的东西也都不可变不行会报哈希类型错误函数返回多个值天然返回元组需要手动转换数据量是否频繁变化不适合非常适合表达“只读配置”最合适容易被误改一句话概括你不希望它被改就用元组你希望它灵活变动就用列表。不建议迷信“元组比列表快”这种说法性能差异在绝大多数业务场景下小到可以忽略真正值得在意的是语义正确。代码一旦把“只读”的意思表达清楚后面维护的人就不会犯低级错误。2. 元组的创建与基础操作新手写元组最容易在创建这一步翻车。我见过太多人以为括号是元组的标志于是写了 t (1)再用 type(t) 一查发现居然是 int。括号确实有提示作用但真正让一个对象变成元组的是逗号不是括号。2.1 五种创建方式一个坑先看代码这些都是我平时教学时必演示的写法# 方式一标准写法 t1 (1, 2, 3) # 方式二省略括号逗号才是关键 t2 1, 2, 3 print(type(t2)) # class tuple # 方式三空元组 t3 () # 方式四单元素元组必须有逗号 t4 (5,) print(type(t4)) # class tuple # 方式五从别的可迭代对象转换 t5 tuple([1, 2, 3]) t6 tuple(abc) # (a, b, c)这里最大的坑就是第四种。很多人写(5)想表达包含一个整数5的元组实际拿到的是整数5。在没有逗号的情况下括号就只承担数学运算或表达式分组的职责(5)就是一个普通整数。判断一个对象是不是元组最可靠的做法不是看括号而是直接 type() 看结果或者数一数有没有逗号。空元组不用逗号这是个例外但平时写代码用空元组的场景也越来越少大多数时候你干脆用 list() 或直接 None 更清晰。单元素元组一定要写成 (value,)这个点我几乎每期课都要强调一次因为它太容易踩了。2.2 索引、切片、拼接与成员判断元组的索引和切片跟列表一模一样从0开始可以用负数从尾部往前数point (10, 20, 30, 40, 50) print(point[0]) # 10 print(point[-1]) # 50 print(point[1:4]) # (20, 30, 40) print(point[::-1]) # (50, 40, 30, 20, 10)切片出来还是元组这是序列类型的通性。拼接和重复也可以用a (1, 2) b (3, 4) print(a b) # (1, 2, 3, 4) print(a * 3) # (1, 2, 1, 2, 1, 2)成员判断用 in 和 not innums (10, 20, 30) print(20 in nums) # True print(99 in nums) # False这里要注意一个性能小常识in 对元组是逐个遍历对集合则接近O(1)。如果你频繁判断某个值在不在大量数据里数据规模上去了之后集合会明显比元组快。元组适合小规模、语义固定的数据不适合当海量数据检索结构。2.3 配合内置函数快速统计元组虽然不能修改但读取能力一点不缺scores (88, 92, 79, 85, 90) print(len(scores)) # 5 print(max(scores)) # 92 print(min(scores)) # 79 print(sum(scores)) # 434 print(scores.count(88)) # 88 出现了几次 print(scores.index(92)) # 92 在哪个位置count 和 index 是元组的两个常用方法。奇怪的是很多初学者习惯了列表的 append、remove反而忘了元组还有这一对读取方法。count 适合统计元素出现次数index 则可以用来找元素位置如果值不存在会抛 ValueError所以用 index 之前建议先用 in 判断一下或者用 try-except 包起来。这些基础方法虽然小但在做操作题的时候几乎都会用到。3. 不可变性元组真正的脾气元组最核心的性格就是不可变。但“不可变”这三个字在不同语境下有不同的解释。我见过不少人以为元组里一旦存了列表整个元组就完全不能动了结果发现 t[1].append(4) 居然能跑通当场懵掉。这一节我把不可变这件事彻底拆开讲。3.1 为什么需要不可变先说设计动机。Python 是一门非常依赖约定的语言它希望代码写出来就能看出意图。列表天生是灵活的你随时可以增删改。但有些数据一旦生成就不该变比如一个函数返回的坐标点一段配置信息一条日志记录的部分字段。如果你用列表调用方一个 append 就能改坏数据而且很难排查。用元组就能从源头解决这个困扰不是“约定大家别改”而是“语法上不让你改”。这个思路跟数据库里的只读视图很像权限没有给到你想改也没办法。所以当你设计接口时凡是内部不希望外部修改的数据返回类型优先考虑元组。同理你写函数接收参数时如果这个参数只读取不修改用元组类型的形参也能顺便提醒调用者这块数据我不会动。另一个重要原因是哈希。可变对象不能作为字典的键因为列表内容一变哈希值就会变字典就乱了。元组不可变所以只要元组里的元素也都是不可变类型它就能放进字典当键、放进集合当元素。这是“不可变”带来的硬性能力很多算法题里都会用到。3.2 浅不可变元组里的“假修改”这是最容易让人困惑的地方。看下面的例子t (1, [2, 3]) t[0] 100 # TypeError: tuple object does not support item assignment t[1].append(4) print(t) # (1, [2, 3, 4])为什么 t[0] 100 会报错t[1].append(4) 却能成功因为元组存的是元素的“引用”元组本身保证的是引用的指向不变不是引用指向的那个对象不能改。t[1] 指向的是一个列表对象列表本身是可变类型append 只是在改那个列表元组里的引用并没有变。换句话说元组只是保证了外层这个“抽屉”不能换对象但抽屉里装的如果是个本身就易变的东西那个东西要变形元组也拦不住。这个特性叫“浅不可变”或者叫“浅哈希”。正因为如此(1, [2, 3]) 就不能当字典键元组本身可以哈希但内部元素列表不可哈希所以整个元组也无法完成哈希。这一点在面试题里经常出现实际开发中你也最好别把一个包含可变量的元组当作稳定标识来用否则数据一变整个逻辑就跟着乱了。3.3 作为字典键和集合元素的门槛元组的哈希能力是有条件的。当你写下 hash((1, 2, 3))Python 能顺利返回一个整数但如果你写 hash((1, [2, 3]))立刻会抛出 TypeError: unhashable type: list。这是因为计算哈希时Python 需要递归地处理所有子元素任何一个子元素不可哈希整个元组就不可哈希。实际使用中这个特点很有用。比如你要用坐标点作为字典的键统计平面上每个点出现的次数那就写成from collections import defaultdict count_dict defaultdict(int) points [(1, 2), (1, 2), (3, 4), (1, 2)] for p in points: count_dict[p] 1 print(dict(count_dict)) # {(1, 2): 3, (3, 4): 1}换成列表作为键就会直接报错。把元组做成字典键的做法在处理“组合标识符”时特别常见。一个小技巧是千万别把浮点数直接当元组键来频繁运算浮点精度会导致哈希结果不稳定宁可转成整数、字符串或者先用 round 处理一下。4. 元组的高手用法创建和读取只是基本功元组真正能提升开发效率的地方在于解包、命名元组和函数参数。这几招能让代码从“能跑”变成“清爽”。4.1 多值返回与并行赋值Python 函数其实只能返回一个对象但你看多少教程都在写“返回多个值”秘密就是元组。下面这个函数把姓名和年龄一并返回def get_user_info(): name 李雷 age 18 return name, age name, age get_user_info() print(name, age) # 李雷 18return name, age 被Python解释器自动包装成元组 (name, age)左边的 name, age 又直接把元组拆开。这一来一回就是并行赋值的经典用法。同理两个变量交换值不需要中间变量a, b 1, 2 a, b b, a print(a, b) # 2 1右边的 b, a 先构成元组 (2, 1)再解包给左边的 a, b。整个过程中根本不用临时变量代码短且意图明确。很多编程竞赛选手和一线开发都用这种方式一看就是老手写法。4.2 星号解包与“不要的变量”元组解包还能配合星号把剩余元素一次性收进列表。这里有个细节星号拿到的永远是列表而不是元组你自己跑一次就知道first, *middle, last (1, 2, 3, 4, 5) print(first) # 1 print(middle) # [2, 3, 4] print(last) # 5如果你只需要其中一部分数据另一个元素完全用不上可以用下划线命名。这是Python社区约定俗成的写法_, maybe, _ (100, 200, 300) print(maybe) # 200不过这里有个小坑如果你连续用两个下划线解释器只是把它们当成两个不同的变量第二个 _ 会覆盖第一个 _ 的值。所以千万别以为 _ 是“丢弃专用语法”它只是一个约定俗成的变量名。真要严格丢弃当你要忽略多个位置时还是老老实实先用索引取值更稳妥。4.3 namedtuple给元组加上“名字”普通元组靠位置访问数据一多就分不清谁是谁。比如 (张三, 25, 北京)你得记清楚0是姓名、1是年龄、2是城市。这个在简单场景下忍忍还行字段一多就是灾难。collections.namedtuple 就是为此而生的解决方案。from collections import namedtuple Person namedtuple(Person, [name, age, city]) p Person(张三, 25, 北京) print(p.name) # 张三 print(p.age) # 25 print(p[0]) # 张三按下标访问依旧好用namedtuple 创建出来的对象本质上还是元组所以它照样不可变也照样支持索引、遍历、解包。但它多出来的好处是字段名代码的可读性提升非常明显。它还带 _replace 方法用来“生成一个新的、替换了某些字段的元组”p2 p._replace(age26) print(p2) # Person(name张三, age26, city北京) print(p) # Person(name张三, age25, city北京)原对象没变还有 _asdict 方法能输出成字典形式偶尔调试或者转给其他地方用很方便。我在做数据抓取或者解析配置文件时经常把一行行记录定义成 namedtuple代码写出来非常清晰。4.4 *args 里藏着的元组函数定义里那个 *args收集到的所有多余参数就是一个元组。这一点很多人会忽略他们只记得“可以用 *args 接收不定长参数”却不清楚 args 到底是什么类型。来个简单例子def add_all(*args): print(type(args)) # class tuple return sum(args) print(add_all(1, 2, 3, 4)) # 10因为 args 是元组所以你在函数内部不能执行 args[0] 100否则会报错。这其实是好事参数进来之后不能被随手改掉保证了函数的稳定性。反过来调用函数时也可以用星号展开一个元组def show(x, y, z): print(x, y, z) values (1, 2, 3) show(*values) # 1 2 3这招在参数已经打包成元组、而函数又需要独立形参的场景非常好用。本质就是把一个元组“拆开”喂给形参跟并行赋值的思想完全一致。5. 元组操作题实战从简单到烧脑光会看语法不叫会真正能上手做题、解决实际问题才算掌握。我整理了三个层次的元组操作题从直接套方法的简单题到需要自己设计算法的综合题每一道都可以在本地练手。5.1 基础题交换、遍历、找极值先来一道热身题给定一个元组把里面的最大值、最小值和总和输出出来。data (17, 5, 23, 8, 14) max_val data[0] min_val data[0] total 0 for num in data: total num if num max_val: max_val num if num min_val: min_val num print(max_val, min_val, total)看起来很简单但如果你只会用内置函数可能不知道怎么手写遍历。我建议练习时两种写法都试一遍先用 max、min、sum 一行搞定再手动循环实现一遍。手动循环能帮你理解元组遍历的本质用内置函数则能让你写出更地道的代码。尤其是 sum 这种函数知道的人多但真正写代码时想不起来用就很可惜。再练一道交换题把元组里的首个元素和最后一个元素换位置注意元组不能改所以要生成新元组。def swap_ends(t): if len(t) 2: return t return (t[-1],) t[1:-1] (t[0],) print(swap_ends((1, 2, 3, 4, 5))) # (5, 2, 3, 4, 1)这里有两个关键点一是单元素元组必须带逗号否则 t[-1] 会被当普通整数参与拼接二是元组切片本身返回元组所以可以用 直接连接。很多人在这一步漏掉逗号导致报错或类型错乱写的时候多看一眼。5.2 进阶题元组列表的排序与去重更常见的是“元组列表”这种结构也就是列表里每个元素都是元组。比如一个坐标点集合 [(1, 3), (2, 1), (3, 2)]现在要求按每个点的第二个值排序。points [(1, 3), (2, 1), (3, 2)] sorted_points sorted(points, keylambda p: p[1]) print(sorted_points) # [(2, 1), (3, 2), (1, 3)]sorted 函数返回的是新列表不会改动原数据这点对不可变思维非常友好。key 参数接收一个函数指定排序依据。如果你想先按第二个值排序第二个值相同再按第一个值排也不用写复杂逻辑直接points [(1, 3), (2, 1), (3, 1)] sorted_points sorted(points, keylambda p: (p[1], p[0])) print(sorted_points) # [(2, 1), (3, 1), (1, 3)]去重的思路也类似把元组放进集合因为元组可哈希所以能自动去重。data [(1, 2), (1, 2), (3, 4), (3, 4), (5, 6)] unique list(set(data)) print(unique) # [(1, 2), (5, 6), (3, 4)]注意集合是无序的所以去重后的顺序跟原始顺序不一定一致。如果你需要保留原始顺序就得用常规去重技巧比如遍历时用 set 判断是否出现过再按原顺序收集。这正是“元组可哈希”在实际问题中的价值把去重从不可行变成了可行。5.3 综合题学生成绩统计这道题适合拿来练手也比较贴近实际。现在有三位学生的信息存储成元组列表每个元组是 (姓名, 语文, 数学)students [ (小明, 88, 92), (小红, 95, 89), (小刚, 79, 97), ] for name, chinese, math in students: print(f{name}: 总分 {chinese math}) total_chinese sum(s[1] for s in students) total_math sum(s[2] for s in students) print(语文总分, total_chinese) print(数学总分, total_math) top_student max(students, keylambda s: s[1] s[2]) print(总分最高, top_student[0], top_student[1] top_student[2])这里有几个非常实用的写法for 循环直接解包元组sum 配合生成器表达式统计某一列max 配合 key 取出“某条件最强”的完整元组。整个过程没有修改任何原始元组只是读取计算这本身就是元组最合适的使用场景。如果数据量特别大我建议把 students 里每个元组换成 namedtuple代码的可读性会进一步上升比如 top_student.name 显然比 top_student[0] 更容易理解。看到这个点说明你已经把元组从基础语法用到了工程化层面。6. 从报错到排查我踩过的元组坑元组相关的报错不算多但每一个都很有迷惑性。我把自己教学和开发中频繁遇到的几个问题整理出来像速查表一样放在这里遇到类似症状直接对号入座。6.1 TypeError: tuple object does not support item assignment这个报错几乎人人都遇过。意思是元组不允许通过下标赋值像 t[0] 100 这种操作会直接触发。出现这个报错通常有两种情况一是你真的试图修改一个元组那就改成列表或者新建一个元组二是你在写代码时不小心把本该是列表的数据写成了元组比如函数返回值返回了元组接收后想改却改不了。解决办法看场景。如果数据语义上本来就该被改那换成列表如果只是需要“改掉某个字段同时保留其他字段”可以用切片拼接生成新元组t (1, 2, 3) t t[:1] (99,) t[2:] print(t) # (1, 99, 3)这种方式虽然不能原地修改却完全符合元组的设计思路生成一个新版本旧数据保持原样。在写函数式风格代码时这种“不破坏原数据”的思路反而更安全。6.2 忘写逗号引发的“隐患”前面提过 (5) 不是元组而是整数这里再说一个更隐蔽的变体t (5) print(type(t)) # class int t 5, print(type(t)) # class tuple有人可能会说就算 t (5) 类型是 int代码不也照样能跑吗确实能跑但如果后面你写了 t[0]、遍历 t 或者把 t 当作序列传进别的函数就会得到各种莫名其妙的 TypeError: int object is not iterable。这种错在业务代码里特别难找因为它报错的位置往往离写错的位置很远。排查技巧很直接一旦看到“xxx is not iterable”先回头检查你“以为自己是元组”的变量到底是不是元组用 type() 验一下。6.3 sorted() 与 .sort()元组不能用 sort列表有 .sort() 方法可以原地排序但元组没有。原因很简单排序也是修改元组不允许。所以你对元组调用 t.sort()会得到 AttributeError: tuple object has no attribute sort。正确的做法是用内置函数 sorted(t)它会返回一个排好序的新列表。t (3, 1, 2) s sorted(t) print(s) # [1, 2, 3] print(t) # (3, 1, 2)有个非常容易忽略的点sorted 返回的是列表不是元组。如果你后续需要元组记得再套一层 tuple(sorted(t))。这种隐式类型转换最容易埋坑特别是函数返回类型写得不严格的时候一个 sorted 可能悄悄把你的元组变成列表导致下游代码的类型假设全部失效。6.4 修改“元组里的列表”到底算不算违反了不可变这个问题几乎每次讲元组都会有人问。曾经有个学生在代码里写t (1, [2, 3]) t[1].append(4) print(t) # (1, [2, 3, 4])他非常困惑明明元组不可变为什么数据变了这时候需要回到第3节讲的“浅不可变”概念。元组锁住的是“引用关系”你不能让 t[1] 指向其他对象但 t[1] 指向的那个列表对象它可以自由变化。要想真正做到“元组内部的所有东西都不可变”就得保证元组里只放不可变类型比如整数、字符串、元组本身。如果放了列表、字典、集合那么整个元组就不是完全不可变的。这个细节在工程上有一个实际建议如果你希望某个数据整体只读别偷懒只用元组还得确保元组里的成员都是不可变类型。如果有可变成员要么改成不可变结构要么明确加注释提醒大家。千万别以为“用元组就万事大吉”。我个人在实际项目里遇到最多次数的元组坑就是类型误判和浅不可变这两个。很多看似诡异的Bug归根结底就是对“不可变”理解得太表面。只要把第3节吃透这一类的坑都能避开。元组在Python里看着简单用好了能极大提升代码的语义清晰度和安全性。希望这篇文章能把你在元组上踏过的坑填掉也让你以后写代码时多一份对“不可变”的敬畏。