Python 系列(十三) Tuple 元组实战,搞懂不可变特性与 enumerate() 遍历
1 | 作者:李晓辉 |
- Python 系列(一) 从零开始认识 Python,入门其实没那么难
- Python 系列(二) 学 Python 的第一步,把开发环境搭建好
- Python 系列(三) 基础语法避坑指南,缩进、大小写、续行与注释
- Python 系列(四) Python 输入输出详解,print()、input() 与常见避坑指南
- Python 系列(五) 数值类型与运算符详解,整除、浮点精度与进制转换实战
- Python 系列(六) 字符串处理实战,查找、替换、切片与日志分析
- Python 系列(七) 类型转换详解,int()、float()、str() 与金额计算避坑
- Python 系列(八) Python 流程控制入门,掌握 if、elif 和 else
- Python 系列(九) Python 逻辑运算符详解,and、or、not 一篇搞懂
- Python 系列(十) while、for 循环实战,彻底搞懂 range、break 与 continue
- Python 系列(十一) list、tuple、set、dict,到底该怎么选?
- Python 系列(十二) List 列表实战,搞定数据增删改查与批量处理
- Python 系列(十三) Tuple 元组实战,搞懂不可变特性与 enumerate() 遍历
- Python 系列(十四) Set 集合实战,搞定数据去重与集合运算
在上一篇文章中,我们学习了 Python 的
list,掌握了列表的创建、索引、切片、增删改、排序和循环遍历。但是,在实际开发中,并不是所有数据都需要随时修改。
比如,一台服务器的 IP 地址和 SSH 端口、一组固定的状态码映射,或者巡检过程中返回的主机名与检查结果。如果这些数据在创建后不希望被意外修改,应该怎么办?
这时,我们就可以考虑使用 Python 的另一种内置序列类型:
tuple,也就是元组。本文继续以 Linux 运维场景为例,学习元组的创建、访问、切片、解包和不可变特性,并重点掌握
enumerate()如何同时获取元素的索引和值。
从服务器配置信息认识 Tuple
假设你是一名 Linux 运维工程师,需要编写一个自动化脚本,保存某台服务器的基本连接信息。
例如,服务器名称为 web01,IP 地址为 192.168.1.10,SSH 端口为 22。
我们可以使用列表保存这些信息:
1 | server_info = ["web01", "192.168.1.10", 22] |
运行结果:
1 | ['web01', '192.168.1.10', 22] |
这样当然没有问题。
但是,仔细想一想:如果这组信息在程序运行过程中不需要修改,我们真的需要使用一个可以随意增删改的列表吗?
例如,某个地方不小心执行了下面的代码:
1 | server_info[1] = "192.168.1.100" |
运行结果:
1 | ['web01', '192.168.1.100', 22] |
原本的 IP 地址就被修改了。
如果这组数据代表的是固定的服务器配置信息,那么这种意外修改可能导致后续程序连接错误的服务器。
这时,我们就可以使用元组:
1 | server_info = ("web01", "192.168.1.10", 22) |
运行结果:
1 | ('web01', '192.168.1.10', 22) |
元组与列表一样,都可以保存多个元素,也支持索引、切片和循环遍历。
最大的区别在于:元组创建后,不能直接添加、删除或替换其中的元素。
例如:
1 | server_info = ("web01", "192.168.1.10", 22) |
运行时会出现错误:
1 | Traceback (most recent call last): |
这就是元组的不可变特性。
当然,不可变并不意味着元组在所有场景下都比列表更好。选择哪种类型,仍然需要结合数据的用途来判断。
Tuple 的特点
理解元组之前,先记住它的几个核心特点。
- 有序: 元素按照指定顺序排列,可以通过索引访问,也支持切片。
- 不可变: 创建后,不能直接修改元组中的元素,也不能直接增删元素。
- 允许重复: 同一个值可以在元组中出现多次。
- 可以保存不同类型的数据: 例如字符串、整数、浮点数,甚至列表和字典。
- 可以遍历: 支持
for循环,也可以配合enumerate()使用。
例如:
1 | server_info = ("web01", "192.168.1.10", 22, "web01") |
运行结果:
1 | ('web01', '192.168.1.10', 22, 'web01') |
可以看到,元组中允许重复出现 web01,并且 len() 可以获取元组中元素的数量。
那么,为什么需要使用不可变的元组,而不是直接使用列表呢?
为什么要使用 Tuple,而不是 List?
可以从几个方面理解。
避免意外修改。
对于创建后不应该改变的数据,使用元组可以避免直接通过索引替换元素,也不能直接调用列表的 append()、remove() 等修改方法。
例如,保存固定的服务端口配置:
1 | service_ports = ("ssh", 22) |
运行结果:
1 | ('ssh', 22) |
这组信息可以被读取和传递,但不能直接修改其中的元素。
表达固定的数据结构。
例如,一次巡检返回的结果可以包含主机名、CPU 使用率和检查状态:
1 | check_result = ("web01", 75.6, "OK") |
运行结果:
1 | web01 |
这里的三个元素分别代表主机名、CPU 使用率和检查状态。元组适合表达这种有固定位置含义的数据。
不过,如果字段很多,或者需要通过字段名称访问数据,那么字典或专门的数据类可能更合适。
作为某些需要不可变对象的场景中的数据载体。
例如,元组在满足条件时可以作为字典的键,也可以放入集合中。前提是元组中的所有元素都必须是可哈希的。
1 | server_ports = { |
运行结果:
1 | SSH |
这里使用 (服务器名称, 端口号) 作为字典的键,能够将两个值组合起来标识一项配置。
需要注意,包含列表等不可哈希对象的元组不能直接作为字典的键。
至于性能方面,元组有时会比列表占用更少的内存,但具体情况取决于数据和使用方式。不要仅仅因为元组可能更节省内存,就把所有列表都改成元组。
创建 Tuple
Python 提供了几种创建元组的方式。最常见的是使用圆括号,也可以通过 tuple() 构造函数创建。
使用圆括号创建元组
最直观的方式,就是使用一对圆括号 (),并使用逗号分隔其中的元素。
1 | servers = ("web01", "web02", "db01") |
运行结果:
1 | ('web01', 'web02', 'db01') |
这三组数据分别保存服务器名称、端口号和 CPU 使用率。
与列表不同,元组通常使用圆括号表示,而列表使用方括号表示。
创建空元组
如果暂时没有数据,也可以创建一个空元组:
1 | empty_tuple = () |
运行结果:
1 | () |
空元组中没有任何元素,因此长度为 0。
也可以使用 tuple() 创建空元组:
1 | empty_tuple = tuple() |
运行结果:
1 | () |
这两种写法都可以。
创建只有一个元素的元组
这里有一个 Python 初学者经常踩到的坑。
如果想创建只包含一个服务器名称的元组,下面的写法正确吗?
1 | server = ("web01") |
运行结果:
1 | web01 |
你可能会发现,变量 server 并不是元组,而是字符串。
为什么?
因为在 Python 中,决定元组是否成立的关键是逗号,而不是圆括号。
正确的写法应该是:
1 | server = ("web01",) |
运行结果:
1 | ('web01',) |
注意最后的逗号。
如果不使用圆括号,也可以创建元组:
1 | server = "web01", |
运行结果:
1 | ('web01',) |
所以,如果需要创建只有一个元素的元组,一定要记住这个细节。
使用 tuple() 构造函数
除了使用圆括号,还可以使用 tuple() 将可迭代对象转换为元组。
例如,将列表转换为元组:
1 | servers = ["web01", "web02", "db01"] |
运行结果:
1 | ('web01', 'web02', 'db01') |
也可以将字符串转换为元组:
1 | letters = tuple("Linux") |
运行结果:
1 | ('L', 'i', 'n', 'u', 'x') |
这里需要注意,tuple() 会从可迭代对象中逐个获取元素,而不是将整个对象作为单个元素放入元组。
例如:
1 | numbers = tuple(range(1, 6)) |
运行结果:
1 | (1, 2, 3, 4, 5) |
range(1, 6) 产生从 1 到 5 的整数序列,tuple() 则将这些整数收集到一个元组中。
直接使用逗号创建元组
在 Python 中,圆括号通常可以省略,只要元素之间使用逗号分隔即可。
1 | server_info = "web01", "192.168.1.10", 22 |
运行结果:
1 | ('web01', '192.168.1.10', 22) |
这种写法同样创建了元组。
不过,在实际编写教程和业务代码时,通常建议使用圆括号,让数据结构更加直观,方便阅读和维护。
通过索引访问 Tuple 元素
元组是有序序列,因此可以像列表一样通过索引访问元素。
例如:
1 | server_info = ("web01", "192.168.1.10", 22) |
运行结果:
1 | web01 |
元组的索引同样从 0 开始。
| 索引 | 元素 | 含义 |
|---|---|---|
0 | web01 | 服务器名称 |
1 | 192.168.1.10 | IP 地址 |
2 | 22 | SSH 端口 |
如果需要访问最后一个元素,也可以使用负数索引:
1 | server_info = ("web01", "192.168.1.10", 22) |
运行结果:
1 | 22 |
其中,-1 表示最后一个元素,-2 表示倒数第二个元素。
如果索引超出范围,同样会引发 IndexError。这一点与列表完全相同。
使用切片获取部分元素
除了访问单个元素,元组也支持切片。
切片的基本语法仍然是:
1 | tuple[start:stop:step] |
例如:
1 | server_info = ( |
运行结果:
1 | ('web01', '192.168.1.10', 22) |
第一个切片取出索引 0 到 2 的元素,第二个切片则取出从索引 3 开始直到末尾的元素。
与列表一样,切片包含开始位置,但不包含结束位置。
元组切片的另一个特点是:切片会返回一个新的元组,而不是列表。
1 | server_info = ("web01", "192.168.1.10", 22) |
运行结果:
1 | ('web01', '192.168.1.10') |
因此,如果需要从元组中提取一部分数据,切片仍然是非常方便的操作。
Tuple 的不可变特性
前面多次提到,元组创建后不能直接修改其中的元素。
现在,我们再具体看看哪些操作不允许执行,以及不可变特性需要注意什么。
不能直接修改元组中的元素
例如:
1 | servers = ("web01", "web02", "db01") |
运行时会引发 TypeError,因为元组不支持通过索引直接赋值。
1 | Traceback (most recent call last): |
同样,元组也没有列表的 append()、extend()、remove() 和 clear() 等修改方法。
例如:
1 | servers = ("web01", "web02", "db01") |
运行时会引发 AttributeError,因为元组没有 append() 方法。
1 | Traceback (most recent call last): |
如果业务确实需要修改数据,可以根据需求将元组转换为列表,完成修改后再转换回元组。
1 | servers = ("web01", "web02", "db01") |
运行结果:
1 | ('web01', 'web02', 'db01', 'cache01') |
需要注意,这并不是直接修改原元组,而是创建了新的列表,再将结果重新赋值给变量 servers。
元组不可变,不代表内部对象一定不可变
这个知识点很重要。
假设我们创建一个元组,其中包含一个列表:
1 | server_info = ("web01", ["running", "healthy"]) |
运行结果:
1 | ('web01', ['running', 'healthy']) |
我们尝试修改元组中的第二个元素:
1 | server_info[1] = ["stopped"] |
这会引发 TypeError,因为不能替换元组中索引 1 对应的对象。
1 | Traceback (most recent call last): |
但是,如果我们修改这个列表对象本身呢?
1 | server_info = ("web01", ["running", "healthy"]) |
运行结果:
1 | ('web01', ['running', 'healthy', 'monitored']) |
这段代码却可以正常运行。
为什么?
因为元组中保存的是对列表对象的引用。我们没有替换元组中的元素,而是修改了这个列表对象内部的内容。
因此,元组的不可变特性约束的是元组自身包含的元素引用,而不是递归地冻结所有内部对象。
如果希望数据结构中的内容也不被意外修改,就不能简单地认为使用元组就足够了,还需要考虑内部对象的可变性。
Tuple 解包:一次获取多个变量
在前面的例子中,我们使用索引获取服务器名称、IP 地址和端口号。
例如:
1 | server_info = ("web01", "192.168.1.10", 22) |
运行结果:
1 | web01 192.168.1.10 22 |
如果每次都需要通过索引取出数据,代码就会显得有些重复。
这时,可以使用解包。
1 | server_info = ("web01", "192.168.1.10", 22) |
运行结果:
1 | web01 |
Python 会按照位置,将元组中的元素依次赋值给左侧对应的变量。
普通解包要求左侧变量的数量与元组中的元素数量一致,否则就会引发 ValueError。
例如:
1 | server_info = ("web01", "192.168.1.10", 22) |
运行时会报错,因为元组有三个元素,而左侧只有两个变量。
1 | Traceback (most recent call last): |
如果只希望获取部分元素,也可以使用带星号的解包语法:
1 | servers = ("web01", "web02", "db01", "cache01") |
运行结果:
1 | web01 |
这里的 first 保存第一个元素,而 others 保存剩余元素,并且是一个列表。
需要注意,即使原始数据是元组,带星号的解包变量也会得到列表。
解包在处理固定结构的数据时非常实用。例如,函数可以返回多个值,而调用方可以使用多个变量一次接收它们。
1 | def get_server_info(): |
运行结果:
1 | web01 |
这里函数返回的多个值实际上会被打包成一个元组,再由调用方解包。
使用 enumerate() 同时获取索引和值
接下来,学习一个非常实用的内置函数:enumerate()。
假设我们有一组服务器名称,需要逐台输出它们的编号。
1 | servers = ("web01", "web02", "db01", "cache01") |
运行结果:
1 | web01 |
这种写法可以遍历所有服务器,但如果还需要知道当前服务器是第几台,就需要额外处理索引。
一种方式是使用 range() 和 len():
1 | servers = ("web01", "web02", "db01", "cache01") |
运行结果:
1 | 0 web01 |
这种写法可以正常工作,但如果只是为了同时获取索引和值,还有更简洁的方式:enumerate()。
直接使用 enumerate()
1 | servers = ("web01", "web02", "db01", "cache01") |
运行结果:
1 | (0, 'web01') |
可以看到,enumerate() 会在遍历过程中,为每个元素配上一个计数值。
默认情况下,计数从 0 开始。
每次循环,enumerate() 都会产生一个包含索引和值的二元组,例如 (0, 'web01')。
这也正好说明了为什么理解元组很重要:enumerate() 返回的每一项,就是一个包含两个元素的元组。
使用解包获取索引和值
既然每次循环得到的是一个元组,我们就可以直接使用解包,同时获取索引和值。
1 | servers = ("web01", "web02", "db01", "cache01") |
运行结果:
1 | 0 : web01 |
这里的 index 接收索引,server 接收对应的服务器名称。
与下面这种写法相比:
1 | for index in range(len(servers)): |
enumerate() 通常更直观,也不需要手动通过索引再次访问元素。
因此,当你需要同时获取元素的索引和值时,可以优先考虑 enumerate()。
使用 start 参数指定起始编号
默认索引从 0 开始,但在输出巡检报告时,我们通常更习惯从第 1 台服务器开始编号。
这时可以使用 start 参数:
1 | servers = ("web01", "web02", "db01", "cache01") |
运行结果:
1 | 第 1 台服务器:web01 |
需要注意,start=1 只会改变 enumerate() 提供的计数起点,并不会修改元组本身的索引。
也就是说,元组中 web01 的实际索引仍然是 0,只是输出编号从 1 开始。
同时遍历两组关联数据
假设我们有一组成绩等级,以及每个等级对应的分数范围。
1 | grades = ("A", "B", "C", "D", "F") |
运行结果:
1 | A : 90-100 |
这里通过 enumerate() 获取等级的索引,再用相同索引访问分数范围。
这种写法能够运行,但它依赖两组元组的元素数量和排列顺序保持一致。如果两组数据长度不一致,或者顺序发生变化,就容易产生问题。
如果只是希望将两组数据按位置配对,可以使用 zip():
1 | grades = ("A", "B", "C", "D", "F") |
运行结果:
1 | A : 90-100 |
zip() 会将多个可迭代对象中相同位置的元素配对。默认情况下,当其中一个对象耗尽时,遍历就会结束。
如果希望同时获得配对后的编号和值,也可以组合使用:
1 | grades = ("A", "B", "C", "D", "F") |
运行结果:
1 | 第 1 档:A,分数范围:90-100 |
这个例子把前面学到的几个知识点串联了起来:元组、循环遍历、解包、enumerate() 和 zip()。
Tuple 与 List 如何选择?
学习完元组之后,一个常见的问题就是:既然列表和元组都能保存多个元素,什么时候应该选择哪一种?
可以参考下面的对比。
| 对比项 | List 列表 | Tuple 元组 |
|---|---|---|
| 表示方式 | [] | (),或使用逗号构造 |
| 是否有序 | 有序 | 有序 |
| 是否允许重复 | 允许 | 允许 |
| 是否可变 | 可变 | 不可变 |
| 是否支持索引 | 支持 | 支持 |
| 是否支持切片 | 支持 | 支持 |
| 是否支持循环遍历 | 支持 | 支持 |
| 常见场景 | 动态任务清单、待处理数据 | 固定记录、多个返回值、不可变键 |
例如,服务器巡检清单通常会随着业务变化而增加或删除服务器,这时使用列表就比较合适:
1 | servers = ["web01", "web02", "db01"] |
运行结果:
1 | ['web01', 'web02', 'db01', 'cache01'] |
而对于一次巡检过程中形成的固定记录,例如主机名、IP 地址和端口号,则可以考虑使用元组:
1 | server_info = ("web01", "192.168.1.10", 22) |
运行结果:
1 | web01 192.168.1.10 22 |
当然,这只是常见的选择方式,并不是硬性规定。元组中的元素可以是可变对象,列表也可以被程序约定为不修改。真正需要关注的是数据的语义、是否需要修改,以及后续如何使用这些数据。
综合实战:生成服务器巡检报告
现在,我们将元组、解包和 enumerate() 组合起来,模拟一个简单的服务器巡检报告。
假设巡检程序已经获得几台服务器的检查结果,每条记录包含主机名、CPU 使用率和检查状态。
1 | #!/usr/bin/env python3 |
运行结果:
1 | 服务器巡检报告 |
这个示例中有几个值得注意的地方。
check_results是一个元组,其中的每个元素又是一个元组。- 内层元组保存一台服务器的固定记录,包括主机名、CPU 使用率和检查状态。
enumerate(check_results, start=1)为每条记录提供编号。for index, (hostname, cpu_usage, status)使用嵌套解包,一次获取编号和记录中的三个字段。
需要注意,实际巡检时,CPU 使用率和状态通常由程序动态采集,这里只是用预先准备的数据演示如何组织和遍历记录。
当数据量增加时,这种结构能够让代码保持清晰。不过,如果记录字段越来越多,或者需要通过字段名称访问数据,字典、数据类等结构可能更加合适。
常见 Tuple 操作速查
| 操作 | 作用 | 示例 |
|---|---|---|
() | 创建空元组 | items = () |
(x,) | 创建单元素元组 | items = ("web01",) |
tuple(iterable) | 将可迭代对象转换为元组 | tuple([1, 2, 3]) |
items[i] | 通过索引访问元素 | items[0] |
items[-1] | 获取最后一个元素 | items[-1] |
items[start:stop] | 获取切片 | items[:2] |
x in items | 判断元素是否存在 | "web01" in items |
items.count(x) | 统计元素出现次数 | items.count("web01") |
items.index(x) | 查找第一个匹配元素的索引 | items.index("web01") |
len(items) | 获取元组长度 | len(items) |
a, b = items | 解包元组 | hostname, port = items |
enumerate(items) | 遍历时同时获取计数和值 | enumerate(items) |
zip(a, b) | 将多个可迭代对象按位置配对 | zip(grades, points) |
还需要记住几个常见规则:
- 元组有序、允许重复,但不能直接替换、添加或删除其中的元素。
- 创建单元素元组时,元素后面的逗号不能省略。
- 元组切片会返回新的元组。
- 元组不可变,不代表其内部包含的列表或字典也不可变。
enumerate()每次迭代提供一个包含计数和值的元组。- 如果只需要同时获取索引和值,优先考虑
enumerate();如果需要配对遍历多个序列,可以考虑zip()。
写在最后
回到最开始的问题:为什么需要使用 Tuple,而不是直接使用 List?
答案并不是元组在所有情况下都更好,而是不同的数据结构适合不同的需求。
如果数据需要频繁添加、删除或修改,列表通常更加方便;如果需要表达一组有顺序、创建后不应直接修改的数据,元组则是一个不错的选择。
通过这篇文章,我们学习了元组的创建、索引、切片、不可变特性和解包,也掌握了 enumerate() 如何在遍历时同时提供索引和值,并通过服务器巡检报告将这些知识点串联起来。
下一篇文章,我们将继续学习 Python 的 set,看看如何使用集合去除重复元素、进行成员判断,以及利用交集、并集和差集解决实际的数据处理问题。
当你逐渐理解 list、tuple 和 set 的区别之后,就能在编写 Python 自动化脚本时,根据数据的特点选择合适的类型,而不是只知道把所有数据都放进列表。
