Skip to content
This page has been auto-translated and may contain errors.View in English

元组和集合

docs.scrimba.com

你已经了解了列表。Python 还有两种更多的集合类型,可以解决列表无法解决的问题。元组保存一组固定的值,这些值永远不会改变。集合只保持唯一值,让你可以瞬间检查成员资格,无论集合有多大。

Python 的集合工具包有四种类型。列表字典处理大多数常见情况。元组和集合解决特定情况:不可变性是优势的固定记录,以及优先考虑 O(1) 成员资格测试的唯一值集合。

除了 listdict,Python 还给你 tuple(一个创建后无法改变的固定长度序列)和 set(一个无序的唯一值集合,基于哈希表构建,与使 dict 快速的按内容查找结构相同)。下面每个决定都由这个分裂驱动:元组是可哈希的(其内容可以简化为单个数字,因此可以充当字典键或集合成员),列表不是。根据你需要集合做什么来选择,而不是根据你习惯性选择的那个。

元组

元组是一组有序的值,在创建后无法改变。圆括号定义一个元组,但它们是可选的。逗号才是真正使其成为元组的。单项元组需要尾随逗号。

元组是不可变序列。逗号而非括号是创建元组的东西。当所有元素也都是可哈希的时,不可变性使它们成为可哈希的,这打开了列表无法填补的用例:字典键、集合成员和固定结构记录。

tuple 是一个创建后无法改变的固定长度序列。因为其中没有任何内容移动,只要每个元素本身都是可哈希的,Python 就可以为其计算单个哈希(一个源自其内容的数字),这正是让元组可以充当字典键或集合成员而列表不能的原因。索引和切片的工作方式完全与列表相同;没有项目赋值,所以当该行运行时 point[0] = 99 会引发 TypeError。单项形式 (42,) 需要尾随逗号。没有它,括号只会分组,你得到的是裸值 42

python
point = (10, 20)
rgb = (255, 128, 0)
dimensions = (1920, 1080)
single = (42,)            # 单项元组需要尾随逗号
also_tuple = 42, 99       # 括号是可选的;逗号使其成为元组

按索引访问的工作方式完全与列表相同。尝试改变一个项目会引发 TypeError

索引、切片和负索引的工作方式与列表完全相同。任何通过索引赋值的尝试都会引发 TypeError;这是有意的,不是限制。

从元组读取,通过索引、负索引或切片,行为完全与列表相同。写入是不同的地方:没有项目赋值,所以当该行运行时 point[0] = 99 会引发 TypeError。这正是选择元组而不是列表的全部要点。这是一个你和之后的每个读者都可以信任不会在你下面改变的值。

python
point = (10, 20)
point[0]    # 10
point[1]    # 20
point[-1]   # 20

point[0] = 99    # TypeError: 'tuple' object does not support item assignment
Juno元组 元组是一组有序的值,在创建后无法改变。逗号是创建它的东西,不是括号,这就是为什么 (42,) 需要那个孤独的尾随逗号。尝试重新赋值一个项目,你会得到 TypeError,这种被锁定的感觉正是你选择元组的原因。
Juno元组 元组是不可变序列,逗号使其成为元组,不是括号。这种不可变性是一个特性:当其内容也是可哈希的时,它使元组成为可哈希的,所以它可以是字典键或集合成员,而列表不能。项目赋值通过设计引发 TypeError
Juno元组 读取元组与列表匹配;写入是缺失的部分,所以 point[0] = 99 在运行时引发 TypeError。依靠这一点:元组是你在需要保证下游没有任何东西会重写它时传递的值。单项 (42,) 陷阱会对每个人造成一次。

何时使用元组

当你有一小组相关的值属于一起且不会改变时,使用元组。坐标 (x, y)、颜色 (r, g, b)、名称-分数对 ("张三", 87)。固定的结构向阅读代码的任何人表示这个组被视为单个单元。

元组传达固定结构:一组值,其中位置有含义且组作为一个单位处理。它们的可哈希性使它们作为字典键有效,列表不能是。元组发出的契约是:这些值属于一起,不应该改变。

当你有一个固定形状的记录时使用元组:一个已知数量的字段,其中每个位置意味着特定的东西。因为元组是可哈希的(其内容简化为单个数字),它可以放在任何需要可哈希值的地方:字典键、集合成员,或 functools.lru_cache 背后的缓存参数,它记住函数的结果,以你调用它的参数为键。语义信号也不同于列表。元组说"这些字段属于一起,每个位置有含义"(坐标、颜色),而列表说"一系列相似的东西,其长度可以改变"。

Juno何时使用元组 当你有一个固定的小组,每个位置都意味着什么时,使用元组:坐标、(r, g, b) 颜色、名称和分数对。因为元组是可哈希的,你甚至可以将一个用作字典键。列表不能做到这一点,这在人们第一次尝试时会绊倒他们。
Juno何时使用元组 元组表示固定结构:位置有含义,组作为一个单元传播。这与列表的不同之处,列表说"一个长度可以变化的序列"。由于元组是可哈希的,它们可以作为字典键,而列表会引发 TypeError
Juno何时使用元组 元组用于固定形状的记录,列表用于可变长度的相似事物序列。好处是可哈希性:元组可以放入字典键、集合或 lru_cache 键,列表无法去的所有地方。让类型承载那个契约而不是注释。
python
locations = {}
locations[(40, -74)] = "北京"   # 元组作为字典键,可行
locations[[40, -74]] = "北京"   # 列表作为字典键,TypeError

解包

解包从元组中提取值,并在单行中将每个分配给自己的名称。名称数必须与值数匹配。使用 * 将任何剩余项目捕获到列表中。

解包适用于任何可迭代对象:元组、列表、字符串。目标名称的计数必须与可迭代对象的长度匹配,除非带星号的目标捕获可变长度的切片。不匹配会引发 ValueError。解包是从函数使用多个返回值的惯用方式。

解包适用于任何可迭代的东西(任何你可以循环的东西),而不仅仅是元组,遍历右侧并按顺序将每个值绑定到其目标名称。带星号的目标(*rest)将剩余项目吸入 list,所以计数不再必须完全匹配。计数不匹配会在运行时引发 ValueError。你最常使用的形式是在 for 标题中:for name, score in pairs 在循环时解包每个项目,读起来比手工按索引访问每对要清晰得多。

Juno解包 解包从元组或列表中取出每个值,并在一行中给它自己的名称,如 x, y = point。名称数必须与值数匹配,除非你添加 *rest 来舀起剩余的。这对我来说在我停止到处写 point[0]point[1] 的那一刻点了灯。
Juno解包 解包将每个值分配给自己的名称,带星号的目标如 *rest 捕获可变长度的切片。不匹配的计数引发 ValueError。这是从函数获取多个返回值的干净方式,而不是按索引访问结果。
Juno解包 解包在任何可迭代对象上运行,从左到右绑定;*rest 吸收间隙,裸露的不匹配引发 ValueError。它获得最多回报的地方是 for 标题,for name, score in pairs,这比按索引访问每对要好。
python
point = (10, 20)
x, y = point

print(x)   # 10
print(y)   # 20

first, *rest = [1, 2, 3, 4, 5]
# first = 1, rest = [2, 3, 4, 5]

head, *middle, tail = [1, 2, 3, 4, 5]
# head = 1, middle = [2, 3, 4], tail = 5

命名元组

命名元组是每个位置都有名称的元组。你不用记住 point[0] 是 x 坐标,而是写 point.x。值仍然是不可变的;你得到可读的属性名而不是数字位置。

namedtuple 生成一个行为完全像元组的类,但增加了命名属性访问。它比完整类轻,不可变且自我文档化。当普通元组的位置访问需要注释才能理解时使用它。

collections.namedtuple 是一个类工厂(一个构建并交给你的函数,用来使用一个全新的类)。它返回的类是一个带命名字段的 tuple,所以它花费与普通元组相同的内存,同时读起来更好。你免费得到 _asdict()(将其变成字典)、_replace()(进行一个字段改变的副本,因为你不能改变它),和 _fields。当你需要更多时,默认值或类型注解,typing.NamedTuple 涵盖了注解的情况,一旦你想要方法或可选的可变性,dataclasses.dataclass 是现代选择。

Juno命名元组 命名元组给每个位置一个名称,所以你写 point.x 而不是记住 point[0] 是 x。它仍然完全不可变,在其他方面的表现就像常规元组。阅读代码的你的未来自我会为这些名称感谢你。
Juno命名元组namedtuple 构建一个类似元组的类,具有命名属性访问,不可变且自我文档化,重量小于完整的类。当普通元组的位置需要注释来解释时使用它。过了那之后,dataclass 是现代的升级。
Juno命名元组namedtuple 给你一个元组子类,带有命名字段,成本相同,加上 _replace()_asdict() 免费。一旦你想要默认值、方法或真正的类型提示,跳到 typing.NamedTupledataclass,而不是与工厂对抗。

namedtuple 在标准库中,所以它首先需要一个导入:from collections import namedtuple。导入在模块章节中得到充分处理。

python
from collections import namedtuple

Point = namedtuple("Point", ["x", "y"])
Player = namedtuple("Player", ["name", "score", "level"])

p = Point(10, 20)
p.x    # 10
p.y    # 20

alice = Player("张三", 87, 5)
alice.name    # "张三"
alice.score   # 87

集合

集合是一个独特值的集合,没有保证的顺序。两次添加相同的值什么都不做:集合只保留每个项目的一个副本。使用花括号表示带项目的集合,或使用 set() 创建空集合。

set 是一个自动拒绝重复的无序集合。成员资格测试是 O(1),无论大小如何,这使其成为正确的工具,每当你需要检查一个值是否存在于大型集合中。注意:{} 创建一个空字典,不是空集合;为此使用 set()

set 保存唯一的、可哈希的值,并基于哈希表构建,与字典使用的按内容查找结构相同。这给你 O(1) 平均成员资格、插入和删除(成本随着集合增长保持平坦,而不是随其大小上升)。两个需要计划的后果:只有可哈希值可以进去,所以 intstrtuple 没问题,但 listdictset 不是,迭代顺序遵循内部哈希位置,所以永远不要依赖它稳定。一个语法陷阱:{} 是空字典,不是空集合;使用 set()

Juno集合 集合只保持唯一值,所以添加已经在其中的东西什么都不做,没有错误,没有重复。用花括号和项目构建一个,但为空的那个使用 set(),因为 {} 秘密地是空字典。最后这一点几乎会绊倒每个人。
Juno集合 集合自动丢弃重复,无论多大,成员资格测试都是 O(1),这正是使其成为大型集合上"这是在里面吗?"的正确工具。注意空集合陷阱:{} 是字典,所以使用 set()
Juno集合 集合是独特可哈希值的哈希表:O(1) 成员资格、插入和删除,代价是只有可哈希项可以进去,迭代顺序永远不稳定。不要依靠那个顺序,记住 {} 是字典,所以当你想要空集合时使用 set()
python
tags = {"python", "beginner", "tutorial"}
numbers = {1, 2, 3, 4, 5}
empty = set()    # 不是 {} (那是空字典)

两次添加相同的值不会改变集合:

python
tags.add("python")   # tags 未改变,"python" 已经在其中

何时使用集合

集合是三件事的正确工具:从列表中移除重复项、快速检查某个东西是否在大型集合中、以及比较两个组以找到它们共享或不同的东西。

三个不同的用例驱动集合使用:去重(插入时自动)、O(1) 成员资格测试(与列表的 O(n) 相对)和集合代数(|&-^)。当集合很大且你频繁检查成员资格时,性能差异是实质性的。

三个用例直接来自哈希表:唯一性(插入时拒绝重复)、O(1) 成员资格(in 的成本随着集合增长保持平坦)和集合代数(|&-^)。成员资格测试是在实际代码中获得回报的:对 10,000 个项目的集合进行 in 与对 10 个项目的集合一样快,而对列表进行相同检查是 O(n) 并线性减速。当你发现自己对大型列表重复做 x in some_list 时,一次将其转换为集合通常是解决方案。

Juno何时使用集合 三项集合很擅长的工作:从列表中去除重复项、快速检查大集合中是否有东西、比较两个组以查看它们共享或不同的东西。如果其中任何一个是你在做的事情,集合可能是工具。
Juno何时使用集合 三个驱动:插入时去重、O(1) 成员资格与列表的 O(n)、带 |&-^ 的集合代数。集合越大,检查 in 的次数越多,集合表现越好。
Juno何时使用集合 唯一性、O(1) in 和集合代数,全部来自哈希表。实际的好处是成员资格:重复的 `x in big_list` 每次都是 O(n),所以一次将那个列表转换为集合是通常的解决方案。
python
# 从列表中移除重复项
raw = ["cat", "dog", "cat", "bird", "dog", "cat"]
unique = list(set(raw))   # ["cat", "dog", "bird"] (顺序不保证)
python
# 快速成员资格检查
valid_codes = {"USD", "EUR", "GBP", "JPY"}
code = "EUR"

if code in valid_codes:    # 即使有数千个代码,也是即时查找
    print("Valid")

集合操作

集合支持你在数学课中学过的相同操作:并集(两个集合中的任何东西)、交集(两个集合共享的)和差集(一个有但另一个没有的)。Python 为这些使用运算符符号,每个都有方法等价物。

Python 的集合运算符反映数学符号:| 表示并集,& 表示交集,- 表示差集,^ 表示对称差集。每个运算符都有方法形式(.union().intersection() 等),也接受任何可迭代对象,不仅仅是集合。

运算符形式(|&-^)要求两侧都是集合,如果任一侧是其他东西(如列表),则引发 TypeError。方法形式(.union().intersection() 等)更松散:它们接受任何可迭代对象并为你转换,所以 a.union([1, 2])a | [1, 2] 失败的地方有效。当操作炸裂时,这是要记住的区别。还有原地形式(|=&=-=^=),它们更新左集而不是返回新集,与调用 .update().intersection_update() 等相同。

Juno集合操作 数学课中的四个操作:| 是并集(在两个之中),& 是交集(在两个之中),- 是差集(在一个但不在另一个),^ 是对称差集(在一个但不在两个之中)。每个还有一个拼写的方法如 .union(),如果你偏好词而不是符号。
Juno集合操作| 并集,& 交集,- 差集,^ 对称差集。方法形式(.union() 及其朋友)做相同的事,但接受任何可迭代对象,不仅仅是集合。
Juno集合操作 运算符想要两侧都是集合,否则引发 TypeError;方法形式接受任何可迭代对象,所以 a.union([1, 2])a | [1, 2] 不工作的地方有效。原地形式(|=&=)原地更新而不是返回新集合。
python
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}

a | b    # {1, 2, 3, 4, 5, 6}   (并集:两个中的任何东西)
a & b    # {3, 4}               (交集:仅在两个中)
a - b    # {1, 2}               (差集:在 a 中但不在 b 中)
b - a    # {5, 6}               (差集另一种方式)
a ^ b    # {1, 2, 5, 6}        (对称差集:在一个但不在两个中)

这些也有方法形式:.union().intersection().difference().symmetric_difference()

修改集合

集合是可变的。.add() 添加一个项目。.update() 从任何列表或其他可迭代对象一次添加多个。.remove() 删除一个项目,但如果它不在那里则引发错误。.discard() 如果项目存在则静默删除,如果不存在则什么也不做。

.add() 是 O(1) 平均。.update() 接受任何可迭代对象,相当于在循环中调用 .add().remove() 在丢失时引发 KeyError,而 .discard() 是当存在不确定时的安全选择。.pop() 移除一个任意元素,不是"最后一个",因为集合没有顺序。

.add(x) 以 O(1) 平均插入一个项目;.update(iterable) 添加许多,与 |= 相同。让人们咬住的对是 .remove().discard():两者都删除一个项目,但当项目不存在时 .remove() 引发 KeyError,而 .discard() 静默什么都不做。每当存在不确定时使用 .discard(),所以丢失的项目不是你必须包裹的异常。.pop() 返回并移除某个元素,但哪个是未定义的:集合没有顺序,所以永远不要将 .pop() 视为取"最后一个"项目。

Juno修改集合.add() 放入一个项目,.update() 从列表或其他可迭代对象添加一整批。要分清的对是移除:.remove() 如果项目不在那里则出错,而 .discard() 耸肩继续。当你不确定它是否在集合中时,.discard() 为你节省一个流浪的错误。
Juno修改集合.add() 用于一个,.update() 用于许多来自任何可迭代对象。.remove() 在丢失时引发 KeyError.discard() 是忽略它的安全版本。.pop() 拉出一个任意元素,不是"最后一个",因为集合没有顺序。
Juno修改集合.add() 是 O(1),.update()|=。陷阱是 .remove() 引发 KeyError,而 .discard() 保持安静,所以当存在不确定时默认为 .discard().pop() 返回一个未定义的元素;永远不要将其读为最后一个。
python
tags = {"python", "beginner"}

tags.add("tutorial")          # 添加一个项目
tags.update(["web", "api"])   # 从任何可迭代对象添加多个项目
tags.remove("beginner")       # 移除,如果未找到则引发 KeyError
tags.discard("missing")       # 移除,如果未找到则无错误
tags.pop()                    # 移除并返回一个任意项目
tags.clear()                  # 移除一切

当你不确定项目是否存在时使用 .discard()

冻结集合

冻结集合是创建后无法修改的集合。主要使用原因:冻结集合是可哈希的,所以它们可以用作字典键或存储在其他集合中。

frozensetset 的不可变对应物。它支持所有读操作和集合代数,但不支持变异。其不可变性使其可哈希,意味着它作为字典键或作为另一个集合的成员有效。

frozenset 是一个创建后无法改变的 set。因为其中没有任何内容移动,它获得一个稳定的哈希并本身是可哈希的,所以它可以是字典键或另一个集合的成员,普通 set 都不能做。所有读或返回新集合的东西仍然有效(成员资格、集合代数运算符),而变异方法(addremove 等)消失了。它整洁地适配的情况:一个常数查找表,在程序运行时必不改变,可能需要存放在字典或集合中。

Juno冻结集合 冻结集合是一旦创建就不能改变的集合。那种锁定状态是使其可哈希的原因,所以不像普通集合,你可以将一个用作字典键或将其塞入另一个集合。当你有一个不应该改变的固定允许值组时使用它。
Juno冻结集合frozenset 是不可变的 `set`:所有读和集合代数,没有变异。其不可变性购买可哈希性,所以它在普通 `set` 引发的地方作为字典键或另一个集合的成员有效。对常数查找表很好配合。
Juno冻结集合frozenset 有一个稳定的哈希,所以它去 `set` 不能去的地方:字典键、另一个集合的成员。读和集合代数保留,变异方法消失。干净的使用是必不在运行时改变的常数查找表。
python
valid_statuses = frozenset({"active", "paused", "deleted"})
valid_statuses.add("archived")    # AttributeError, frozenset 是不可变的

选择正确的集合

四种类型,每种有清晰的角色。问你需要对数据做什么,正确的选择通常就跟着来。

集合类型之间的选择是关于什么操作重要以及你的数据有什么约束:可变性、顺序、重复处理和查找策略。

选择部分是性能,部分是含义。dictset 通过散列给出 O(1) 平均查找,所以检查成员资格随着它们的增长保持快速。listtuple 给出 O(1) 按索引访问但 O(n) 成员资格,其中 in 随大小减速。另一轴是类型表示什么:tuple 是一个固定记录,其不可变性购买可哈希性,而 list 是可变长度序列。当你需要一个可哈希集合来嵌套在字典或集合中时,tuplefrozenset 是仅有的两个内建类型可以做到这一点。

Juno选择正确的集合 四种类型,四个清晰的工作:列表用于有序的你将改变的东西,元组用于固定记录,集合用于唯一值和快速"它在这里吗?"检查,字典用于按名称查找东西。问你需要对数据做什么,正确的通常就跟着来。
Juno选择正确的集合 根据数据需要什么来选择:可变性、顺序、重复是否重要,以及你如何查找东西。列表用于你改变的有序序列,元组用于固定记录,集合用于唯一值和 O(1) 成员资格,字典用于键值查找。
Juno选择正确的集合 一半性能,一半含义:`dict` 和 `set` 给出 O(1) 查找,`list` 和 `tuple` 给出 O(1) 按索引但 O(n) 成员资格。`tuple` 也表示"固定记录"并购买可哈希性。当你需要一个可哈希集合来嵌套时,`tuple` 和 `frozenset` 是你的两个选项。
listtuplesetdict
有序是(插入顺序)
可变
重复否(键)
访问方式索引索引不适用
使用情况有序、可改变的序列固定记录唯一值、快速成员资格键值查找

一个快速决策规则:

  • 需要按名称查找东西? → 字典
  • 需要一个你将修改的有序集合? → 列表
  • 有一个固定的相关值组? → 元组
  • 需要唯一值或快速成员资格测试? → 集合

实际应用

使用元组存储固定记录,使用集合跟踪唯一值:

python
home = (39.9042, 116.4074)   # 纬度,经度
office = (39.9015, 116.4073)

home_lat, home_lon = home
print(f"Home: {home_lat}, {home_lon}")

# 使用集合跟踪唯一访问者
visitors = set()
visitors.add("alice")
visitors.add("bob")
visitors.add("alice")    # 已在集合中,静默忽略
visitors.add("carol")

print(f"Unique visitors: {len(visitors)}")
print(f"alice visited: {'alice' in visitors}")
print(f"dave visited:  {'dave' in visitors}")

使用集合跟踪已处理的内容并计算剩余工作:

python
already_processed = {"report_jan.csv", "report_feb.csv"}
all_files = {"report_jan.csv", "report_feb.csv", "report_mar.csv", "report_apr.csv"}

to_process = all_files - already_processed
print(f"Files to process: {sorted(to_process)}")

for filename in sorted(to_process):
    print(f"Processing {filename}...")
    already_processed.add(filename)

print(f"Done. Total processed: {len(already_processed)}")

使用 frozenset 作为常数查找表,并演示与集合代数的 O(1) 成员资格测试:

python
ALLOWED_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"})
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})

# frozenset 上的集合代数返回常规集合
unsafe_allowed = ALLOWED_METHODS - SAFE_METHODS
print(f"Non-safe allowed methods: {unsafe_allowed}")

# frozenset 是可哈希的,所以它可以存储在集合中(普通集合不能)
method_groups = {
    frozenset({"GET", "HEAD", "OPTIONS"}),
    frozenset({"POST", "PUT", "PATCH"}),
    frozenset({"DELETE"}),
}
print(f"Method groups: {len(method_groups)}")

method = "POST"
print(f"Allowed: {method in ALLOWED_METHODS}")
print(f"Safe:    {method in SAFE_METHODS}")

frozenset 带有 O(1) 查找并可以存储在任何需要可哈希类型的地方。两个 frozenset 对象上的集合代数返回普通 set;将结果包裹在 frozenset() 中以保持其不可变。