容器集合
引言
在 1.1 对象与类型 里,每种类型都只装一个值。然而,一门编程语言真正的威力,来自它把许多值聚到一起、并把整组当作一个东西来对待的能力——一份学生名册、一张图像的像素、一张测量数据表。做到这一点的类型就是容器(container),它们是大多数真实 Python 程序耗费时间的地方。
本页介绍三大容器家族,区分它们的问题是元素是怎么存放的? 序列(sequence)按顺序保存元素,因此每个元素都有一个可供索引的位置。集合(set)只保存不重复的元素,丢弃重复项、也不记顺序。映射(mapping)把每个元素与一个键配对,于是你按名字而非位置来查找。三者都见过之后,第 4 节会回到 1.1 的第二条轴——可变性——因为它悄悄支配着它们的行为;第 5 节再讲切片,一次读取整段子序列。本页代码可运行,请边读边试。
1. 序列:列表、元组与 range
你最常用到的容器是有序的序列。两位主力是列表(list)和元组(tuple)。两者都保存一组有序的元素;最关键的区别在于可变性。列表用方括号 [] 写出,是可变的;元组用圆括号 () 写出,是不可变的。
下面的示例各建一个并打印出来——注意两者都能混放不同类型的元素。
示例:一个列表和一个元组
第三种序列 range 表示一个整数的等差数列,却并不把它们全部存下来——range(1, 101) 代表 1 到 100,但只保存起点、终点和步长。它是生成数字序列的惯用方式,常常直接交给 list() 或 tuple()。
示例:用 range 生成数字
range 最多接受三个参数——range(stop)、range(start, stop)、range(start, stop, step)——而且和切片一样,stop 是被排除的。负步长向下计数,空 range 也完全合法。
示例:range 的几种形式
课堂练习:构建序列
- 创建一个包含若干不同类型元素的列表;打印整个列表,并用索引打印其中一个元素(索引从 0 开始)。
- 对元组做同样的事。
- 用
range()构建一个包含 1 到 100 的列表,再构建一个这样的元组。
序列共享一套通用工具——索引、用 in 做成员检查、用 len() 取长度,以及切片(第 5 节)。它们还有方法(method),即附着在对象上、用点号调用的函数。
核心概念:方法
方法是隶属于某个对象、并以点号在其上调用的函数,例如 my_list.append(4)。由于
列表是可变的,许多列表方法会原地修改列表;元组是不可变的,因此没有这类方法。
最常用的列表方法是 append()(在末尾添加一个元素)、extend()(把另一个可迭代对象的所有元素加到末尾)和 pop()(移除并返回某个索引处的元素,默认是最后一个)。+ 运算符把两个序列拼接成一个新序列。
2. 集合
如果说序列精心记着元素的顺序,那么集合则刻意把顺序忘掉。集合是一组互不相同的元素的无序汇集,与数学中的集合相呼应:{1, 2} 和 {2, 1, 1} 是同一个集合。集合是可变的,它不可变的对应物是 frozenset。它擅长两件事:快速的成员检查与去重。
下面的示例一次展示这两件事,以及如何从另一个集合体构建集合。
示例:创建集合与去重
因为集合与数学集合相呼应,它支持那些熟悉的集合运算,每一种都既有方法形式、也有运算符形式。
示例:集合运算
集合的元素必须是可哈希的(hashable),这正是集合能装数字、字符串、元组、却不能装列表的原因。当你需要一个不可变的集合——例如用作字典的键或另一个集合的成员——就用 frozenset。
深入了解:“可哈希”是什么意思?
一个对象如果有一个在其生命周期内永不改变的哈希值,它就是可哈希的;正是这一点让
Python 能把它放进那张令集合成员检查和字典查找飞快的内部表里。不可变的内置类型(数字、
字符串、只含可哈希元素的元组)是可哈希的;可变的(列表、字典、集合)则不是——这正是
为什么 list 不能做集合元素或字典键,而 frozenset 可以。这就是第 4 节那条
可变/不可变界线的实际回报。
课堂练习:集合
- 从
numbers = [1, 2, 2, 3, 4, 4, 5]得到一个只含唯一值的列表。 - 用
a = {1, 2, 3, 4}与b = {3, 4, 5, 6},求出在a或b、但不同时在两者中的值。 - 试着把一个列表加入集合,读读那个错误;再换成元组试试,看看它能成功。
3. 映射:字典
三大家族中的最后一个干脆抛开位置,转而把每个值与一个你自定的键配对。字典(dict)保存键—值对,是日常 Python 中最重要的容器。它是可变的,让你按键、而非按位置来查找一个值。
下面的示例用两种方式创建字典,查找一个值,并分别添加和更新一个键值对。
示例:创建并使用字典
用 [] 查找一个不存在的键会引发 KeyError。当某个键可能不在时,get() 返回 None(或你给定的默认值)而不会崩溃——而 update() 则把另一个字典合并进来。
示例:安全查找与合并
要遍历字典,就迭代它的 keys()、values() 或 items()——最后一个会把键和值一并交给你,等你在 1.3 用到 for 循环时会不断用到它。
易错点:字典的键必须可哈希
键必须是可哈希的(见集合里的“深入了解”),所以你可以用数字、字符串或元组作键—— 但绝不能用列表。而值则可以是任何对象。
课堂练习:字典
- 建一个把三个名字映射到年龄的字典,然后打印某个人的年龄。
- 用
get()查找一个不存在的名字,返回"unknown"而不崩溃。 - 添加一个新人,再更新一个已有的人的年龄。
4. 可变性与标识
我们在 1.1 的两条轴中已经见过可变性;现在手里有了容器类型,可以好好讲讲它了,因为它悄悄支配着赋值、比较、乃至字典键的行为。
列表与元组之间那道根本性的区别,会在好几种操作里显现出来。下面的示例表明:两者都能按索引读取元素,但只有列表允许你重新赋值或删除元素。(那行会在元组上失败的语句被注释掉了——取消注释即可看到错误。)
示例:只有列表能被修改
可变性还解释了 += 的一个微妙行为。下面的示例表明:对可变的列表,+= 原地修改对象,因此其标识不变;对不可变的元组,+= 必须构造一个全新的对象,因此其标识改变。
示例:+= 与对象标识
易错点:两个名称,同一个可变对象
因为名称只是一张标签,b = a 会让两个名称指向同一个对象。如果那个对象是可变的,
通过一个名称所做的改动,会从另一个名称看到:
这种别名(aliasing)对不可变对象无害(你本就无法改变它们),却是可变对象的一大 经典 bug 来源。
从图上看,两个名称共享同一个列表,因此通过 b 所做的修改会从 a 看到:
这里还有一个实际的回报:正是不可变性,让一个对象能被用作字典的键或集合的成员。试着拿一个列表当键,Python 会拒绝,因为键不能在字典脚下悄悄改变。
深入了解:可变/不可变的界线是本质的吗?
一半是。在语言层面,可变性是一个真实、可观察的契约,每一种 Python 实现都遵守它, 也正是它使得不可变对象可被哈希(从而能当字典键或集合成员)。但这条界线也有诚实的 灰色地带。元组是不可变的,可是一个装着列表的元组,却允许你修改里面的那个列表—— 元组自身的引用没有变,但引用所指向的东西可以变。而且,一个类型之所以不可变,往往是 一种设计与实现上的取舍(安全、共享、优化、哈希),而非什么深刻的定律。所以:把可变性 当作一个真实而有用的概念,但要明白这条线是 Python 的设计者画下的,底层有 C 语言层面的 机制在支撑——并非由数学颁布。
课堂练习:可变性实践
- 把一个列表的第二个元素设为
-1,确认它成功;再对一个元组做同样的事,读读那个错误。 - 用
id()查看一个列表在原地修改前后的标识——它保持不变吗?然后用一句话解释:为什么对元组的“修改”需要一个新对象。
5. 切片:读取子序列
索引读取一个元素;切片(slicing)读取一整段子序列。其写法是 sequence[start:stop:step],其中 start 是要包含的第一个索引(默认 0),stop 是要排除的第一个索引,step 是步长(默认 1;负步长表示反向)。冒号是必需的,三个数字各自可选。
下面的示例用几种方式对一个列表切片——改改数字再运行,建立直觉。
示例:对列表切片
课堂练习:切片语法
判断下列各项是否为有效的切片(设想用在 seq[…] 中),并说明其含义:
1:2:1、9:1:-1、1.5:2.3:3.14、:-5:-1、::-1。
深入了解:切片本身也是一个对象
写法 start:stop:step 会构造一个 slice 对象,你也可以显式地创建并复用它:
所以 slice 是一种类型,而 xs[1:10:2] 是 xs[slice(1, 10, 2)] 的语法糖。
小结
容器是 Python 程序存放数据的地方。把任何内置容器放到 1.1 的两条轴上——它装什么、它能不能变:
| 类别 | 类型 | 可变? |
|---|---|---|
| 序列 | list / tuple、range |
list 可变,tuple/range 不可变 |
| 集合 | set / frozenset |
set 可变,frozenset 不可变 |
| 映射 | dict |
可变 |
可变性不只是一个标签:它解释了别名现象、+= 的行为,以及为什么只有不可变(可哈希)的对象才能当字典键或集合成员。有了类型(1.1)与容器(1.2)在手,1.3 控制流程 将让它们动起来——遍历它们、检验它们。