跳转至

容器集合

引言

1.1 对象与类型 里,每种类型都只装一个值。然而,一门编程语言真正的威力,来自它把许多值聚到一起、并把整组当作一个东西来对待的能力——一份学生名册、一张图像的像素、一张测量数据表。做到这一点的类型就是容器(container),它们是大多数真实 Python 程序耗费时间的地方。

本页介绍三大容器家族,区分它们的问题是元素是怎么存放的? 序列(sequence)按顺序保存元素,因此每个元素都有一个可供索引的位置。集合(set)只保存不重复的元素,丢弃重复项、也不记顺序。映射(mapping)把每个元素与一个键配对,于是你按名字而非位置来查找。三者都见过之后,第 4 节会回到 1.1 的第二条轴——可变性——因为它悄悄支配着它们的行为;第 5 节再讲切片,一次读取整段子序列。本页代码可运行,请边读边试。

1. 序列:列表、元组与 range

你最常用到的容器是有序的序列。两位主力是列表(list)元组(tuple)。两者都保存一组有序的元素;最关键的区别在于可变性。列表用方括号 [] 写出,是可变的;元组用圆括号 () 写出,是不可变的。

下面的示例各建一个并打印出来——注意两者都能混放不同类型的元素。

示例:一个列表和一个元组
my_list  = [1, 2, 3, "hello", 4.5]   # 可变
my_tuple = (1, 2, 3, "world", 6.7)   # 不可变
print(my_list)
print(my_tuple)

第三种序列 range 表示一个整数的等差数列,却并不把它们全部存下来——range(1, 101) 代表 1 到 100,但只保存起点、终点和步长。它是生成数字序列的惯用方式,常常直接交给 list()tuple()

示例:用 range 生成数字
numbers = list(range(1, 101))  # 1, 2, ..., 100
print(numbers[:10], "...")     # 只看前十个
print(len(numbers))            # 100

range 最多接受三个参数——range(stop)range(start, stop)range(start, stop, step)——而且和切片一样,stop 是被排除的。负步长向下计数,空 range 也完全合法。

示例:range 的几种形式
print(list(range(5)))          # [0, 1, 2, 3, 4]
print(list(range(2, 8)))       # [2, 3, 4, 5, 6, 7]
print(list(range(0, 10, 3)))   # [0, 3, 6, 9]
print(list(range(5, 0, -1)))   # [5, 4, 3, 2, 1] —— 向下计数
print(list(range(1, 1)))       # [] —— 空 range
课堂练习:构建序列
  1. 创建一个包含若干不同类型元素的列表;打印整个列表,并用索引打印其中一个元素(索引从 0 开始)。
  2. 对元组做同样的事。
  3. range() 构建一个包含 1 到 100 的列表,再构建一个这样的元组。

序列共享一套通用工具——索引、用 in 做成员检查、用 len() 取长度,以及切片(第 5 节)。它们还有方法(method),即附着在对象上、用点号调用的函数。

核心概念:方法

方法是隶属于某个对象、并以点号在其上调用的函数,例如 my_list.append(4)。由于 列表是可变的,许多列表方法会原地修改列表;元组是不可变的,因此没有这类方法。

最常用的列表方法是 append()(在末尾添加一个元素)、extend()(把另一个可迭代对象的所有元素加到末尾)和 pop()(移除并返回某个索引处的元素,默认是最后一个)。+ 运算符把两个序列拼接成一个新序列。

2. 集合

如果说序列精心记着元素的顺序,那么集合则刻意把顺序忘掉。集合是一组互不相同的元素的无序汇集,与数学中的集合相呼应:{1, 2}{2, 1, 1} 是同一个集合。集合是可变的,它不可变的对应物是 frozenset。它擅长两件事:快速的成员检查与去重。

下面的示例一次展示这两件事,以及如何从另一个集合体构建集合。

示例:创建集合与去重
seen = {1, 2, 2, 3}
print(seen)                  # {1, 2, 3} —— 重复的 2 没了
print(2 in seen)             # True —— 成员检查很快

nums = set([1, 1, 2, 3, 3])  # 从列表构建集合
print(nums)                  # {1, 2, 3}
print(set())                 # set() —— 空集合({} 是空字典!)

因为集合与数学集合相呼应,它支持那些熟悉的集合运算,每一种都既有方法形式、也有运算符形式。

示例:集合运算
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
print(a | b)        # 并集     {1, 2, 3, 4, 5, 6}
print(a & b)        # 交集     {3, 4}
print(a - b)        # 差集     {1, 2}
print(a ^ b)        # 对称差   {1, 2, 5, 6}
print({1, 2} <= a)  # 子集判断 True

集合的元素必须是可哈希的(hashable),这正是集合能装数字、字符串、元组、却不能装列表的原因。当你需要一个不可变的集合——例如用作字典的键或另一个集合的成员——就用 frozenset

深入了解:“可哈希”是什么意思?

一个对象如果有一个在其生命周期内永不改变的哈希值,它就是可哈希的;正是这一点让 Python 能把它放进那张令集合成员检查和字典查找飞快的内部表里。不可变的内置类型(数字、 字符串、只含可哈希元素的元组)是可哈希的;可变的(列表、字典、集合)则不是——这正是 为什么 list 不能做集合元素或字典键,而 frozenset 可以。这就是第 4 节那条 可变/不可变界线的实际回报。

课堂练习:集合
  1. numbers = [1, 2, 2, 3, 4, 4, 5] 得到一个只含唯一值的列表。
  2. a = {1, 2, 3, 4}b = {3, 4, 5, 6},求出在 ab、但不同时在两者中的值。
  3. 试着把一个列表加入集合,读读那个错误;再换成元组试试,看看它能成功。

3. 映射:字典

三大家族中的最后一个干脆抛开位置,转而把每个值与一个你自定的键配对。字典(dict保存键—值对,是日常 Python 中最重要的容器。它是可变的,让你按键、而非按位置来查找一个值。

下面的示例用两种方式创建字典,查找一个值,并分别添加和更新一个键值对。

示例:创建并使用字典
scores = {"Ada": 95, "Bob": 88}   # 用花括号写键: 值
also   = dict(Ada=95, Bob=88)     # 用 dict() 构造函数
print(scores["Ada"])              # 95 —— 按键找到,而非按位置
scores["Cleo"] = 91               # 添加一个新键值对
scores["Ada"] = 100               # 更新一个已有的
print(scores)

[] 查找一个不存在的键会引发 KeyError。当某个键可能不在时,get() 返回 None(或你给定的默认值)而不会崩溃——而 update() 则把另一个字典合并进来。

示例:安全查找与合并
scores = {"Ada": 95}
print(scores.get("Bob"))               # None —— 不崩溃
print(scores.get("Bob", 0))            # 0 —— 你选的默认值
scores.update({"Bob": 88, "Ada": 100}) # 合并:加入 Bob,更新 Ada
print(scores)

要遍历字典,就迭代它的 keys()values()items()——最后一个会把键和值一并交给你,等你在 1.3 用到 for 循环时会不断用到它。

易错点:字典的键必须可哈希

键必须是可哈希的(见集合里的“深入了解”),所以你可以用数字、字符串或元组作键—— 但绝不能用列表。而则可以是任何对象。

课堂练习:字典
  1. 建一个把三个名字映射到年龄的字典,然后打印某个人的年龄。
  2. get() 查找一个不存在的名字,返回 "unknown" 而不崩溃。
  3. 添加一个新人,再更新一个已有的人的年龄。

4. 可变性与标识

我们在 1.1 的两条轴中已经见过可变性;现在手里有了容器类型,可以好好讲讲它了,因为它悄悄支配着赋值、比较、乃至字典键的行为。

列表与元组之间那道根本性的区别,会在好几种操作里显现出来。下面的示例表明:两者都能按索引读取元素,但只有列表允许你重新赋值或删除元素。(那行会在元组上失败的语句被注释掉了——取消注释即可看到错误。)

示例:只有列表能被修改
l = [1, 2, 3]
t = (4, 5, 6)

print(l[0], t[0])  # 读取对两者都有效
l[0] = 100         # 可以:列表是可变的
del l[1]           # 可以
print(l)
# t[0] = 100       # TypeError:元组是不可变的

可变性还解释了 += 的一个微妙行为。下面的示例表明:对可变的列表,+= 原地修改对象,因此其标识不变;对不可变的元组,+= 必须构造一个全新的对象,因此其标识改变。

示例:+= 与对象标识
l = [1, 2, 3]; before = id(l); l += [4]; print(before == id(l))  # True  (同一个对象)
t = (1, 2, 3); before = id(t); t += (4,); print(before == id(t)) # False (新对象)
易错点:两个名称,同一个可变对象

因为名称只是一张标签,b = a 会让两个名称指向同一个对象。如果那个对象是可变的, 通过一个名称所做的改动,会从另一个名称看到:

a = [1, 2, 3]
b = a
b.append(4)
print(a)   # [1, 2, 3, 4] —— 意外吧?

这种别名(aliasing)对不可变对象无害(你本就无法改变它们),却是可变对象的一大 经典 bug 来源。

从图上看,两个名称共享同一个列表,因此通过 b 所做的修改会从 a 看到:

memory: 堆内存 objects: o1: list [1, 2, 3, 4] @ 0x5f3a20 names: a -> o1 b -> o1
名称指向对象:a 和 b 是同一个列表上的两张标签,所以通过任一名称修改它,都会同时改变另一个名称看到的内容。

这里还有一个实际的回报:正是不可变性,让一个对象能被用作字典的键或集合的成员。试着拿一个列表当键,Python 会拒绝,因为键不能在字典脚下悄悄改变。

深入了解:可变/不可变的界线是本质的吗?

一半是。在语言层面,可变性是一个真实、可观察的契约,每一种 Python 实现都遵守它, 也正是它使得不可变对象可被哈希(从而能当字典键或集合成员)。但这条界线也有诚实的 灰色地带。元组是不可变的,可是一个装着列表的元组,却允许你修改里面的那个列表—— 元组自身的引用没有变,但引用所指向的东西可以变。而且,一个类型之所以不可变,往往是 一种设计与实现上的取舍(安全、共享、优化、哈希),而非什么深刻的定律。所以:把可变性 当作一个真实而有用的概念,但要明白这条线是 Python 的设计者画下的,底层有 C 语言层面的 机制在支撑——并非由数学颁布。

课堂练习:可变性实践
  1. 把一个列表的第二个元素设为 -1,确认它成功;再对一个元组做同样的事,读读那个错误。
  2. id() 查看一个列表在原地修改前后的标识——它保持不变吗?然后用一句话解释:为什么对元组的“修改”需要一个新对象。

5. 切片:读取子序列

索引读取一个元素;切片(slicing)读取一整段子序列。其写法是 sequence[start:stop:step],其中 start 是要包含的第一个索引(默认 0),stop 是要排除的第一个索引,step 是步长(默认 1;负步长表示反向)。冒号是必需的,三个数字各自可选。

下面的示例用几种方式对一个列表切片——改改数字再运行,建立直觉。

示例:对列表切片
xs = list(range(10))  # [0, 1, ..., 9]
print(xs[2:5])        # [2, 3, 4]
print(xs[:3])         # [0, 1, 2]
print(xs[::-1])       # [9, 8, ..., 0] —— 反转
课堂练习:切片语法

判断下列各项是否为有效的切片(设想用在 seq[…] 中),并说明其含义: 1:2:19:1:-11.5:2.3:3.14:-5:-1::-1

深入了解:切片本身也是一个对象

写法 start:stop:step 会构造一个 slice 对象,你也可以显式地创建并复用它:

s = slice(1, 10, 2)
print(list(range(20))[s])  # [1, 3, 5, 7, 9]

所以 slice 是一种类型,而 xs[1:10:2]xs[slice(1, 10, 2)] 的语法糖。

小结

容器是 Python 程序存放数据的地方。把任何内置容器放到 1.1 的两条轴上——它装什么它能不能变

类别 类型 可变?
序列 list / tuplerange list 可变,tuple/range 不可变
集合 set / frozenset set 可变,frozenset 不可变
映射 dict 可变

可变性不只是一个标签:它解释了别名现象、+= 的行为,以及为什么只有不可变(可哈希)的对象才能当字典键或集合成员。有了类型(1.1)与容器(1.2)在手,1.3 控制流程 将让它们动起来——遍历它们、检验它们。