<Fluent Python > 2. 第二章:序列的数组

文章目录

第二章:序列的数组

你可能已经注意到,前面提到的几个操作同样适用于文本、列表和表格。文本、列表和表格统称为"序列"。[...] FOR 命令也能通用地处理序列。

------Leo Geurts、Lambert Meertens 和 Steven Pemberton,《ABC 程序员手册》

在创造 Python 之前,Guido 曾是 ABC 语言的贡献者------这是一个为期十年的研究项目,旨在为初学者设计一个编程环境。ABC 引入了许多我们今天认为"Pythonic"的理念:对不同类型序列的通用操作、内置的元组和映射类型、缩进表示结构、无需变量声明的强类型等等。Python 如此用户友好并非偶然。

Python 从 ABC 继承了序列的统一处理方式。字符串、列表、字节序列、数组、XML 元素以及数据库查询结果,都共享一套丰富的通用操作,包括迭代、切片、排序和拼接。

理解 Python 中多样的序列类型,可以避免我们重复造轮子;而它们共同的接口则启发我们创建能够恰当支持并利用现有及未来序列类型的 API。

本章的大部分讨论适用于一般的序列,从熟悉的 list 到 Python 3 中新增的 strbytes 类型。本章也会涉及列表、元组、数组和队列的具体主题,但 Unicode 字符串和字节序列的细节留到第 4 章讨论。此外,本章的重点是介绍现成可用的序列类型。如何创建自己的序列类型将是第 12 章的主题。

本章主要内容包括:

  • 列表推导式和生成器表达式的基础
  • 将元组用作记录 vs 将元组用作不可变列表
  • 序列解包与序列模式匹配
  • 切片的读取与赋值
  • 特殊序列类型,如数组和队列

本章的新内容

本章最重要的更新是第 38 页的"序列模式匹配"。这是 Python 3.10 新增的模式匹配特性在本书第二版中首次亮相。

其他变化并非更新,而是对第一版的改进:

  • 新增了序列内部结构的图解和描述,对比了容器序列与扁平序列
  • 简要比较了 listtuple 的性能和存储特性
  • 元组中包含可变元素时的注意事项,以及如何检测这种情况

我将命名元组的内容移到了第 5 章第 169 页的"经典命名元组"中,在那里它与 typing.NamedTuple@dataclass 进行对比。

为了容纳新内容并控制页数,第一版中的"使用 bisect 管理有序序列"一节现在移到了 fluentpython.com 配套网站上。

内置序列概览

标准库提供了丰富的用 C 语言实现的序列类型:

容器序列

可以容纳不同类型的元素,包括嵌套的容器。例如:listtuplecollections.deque

扁平序列

只能容纳一种简单类型的元素。例如:strbytesarray.array

容器序列存放的是它所包含的对象的引用,这些对象可以是任意类型;而扁平序列则将元素的值存储在自身的内存空间中,而不是作为独立的 Python 对象。见图 2-1。

因此,扁平序列更紧凑,但仅限于存储原始的机器值,如字节、整数和浮点数。

Python 中的每个对象在内存中都有一个带有元数据的头部。最简单的 Python 对象 float 包含一个值字段和两个元数据字段:

  • ob_refcnt:对象的引用计数
  • ob_type:指向对象类型的指针
  • ob_fval:存储浮点数值的 C double

在 64 位 Python 构建中,每个字段占 8 字节。这就是为什么一个浮点数数组比一个浮点数元组紧凑得多:数组是单个对象,存储着浮点数的原始值;而元组则由多个对象组成------元组本身及其包含的每个浮点数对象。

另一种划分序列类型的方式是按可变性

可变序列

例如 listbytearrayarray.arraycollections.deque

不可变序列

例如 tuplestrbytes

图 2-2 直观地展示了可变序列如何继承不可变序列的所有方法,并额外实现了一些方法。内置的具体序列类型实际上并不继承自 SequenceMutableSequence 抽象基类(ABC),但它们是注册在这些 ABC 下的虚拟子类------我们将在第 13 章看到。作为虚拟子类,tuplelist 能通过以下测试:

python 复制代码
>>> from collections import abc
>>> issubclass(tuple, abc.Sequence)
True
>>> issubclass(list, abc.MutableSequence)
True

记住这些共性:可变 vs 不可变;容器 vs 扁平。它们有助于将一个序列类型的知识推广到其他序列类型。

最基本的序列类型是 list:可变容器。相信你对列表已经非常熟悉,所以我们直接进入列表推导式------这是一种构建列表的强大方式,但有时因其语法乍看起来不寻常而被低估。掌握列表推导式将为生成器表达式打开大门,而生成器表达式可以产生元素来填充任何类型的序列。这两者都是下一节的主题。

列表推导式与生成器表达式

快速构建序列的方式是使用列表推导式(如果目标是列表)或生成器表达式(用于其他类型的序列)。如果你不每天使用这些语法形式,我敢说你错过了编写更具可读性且通常更快的代码的机会。

如果你怀疑这些结构"更具可读性",请继续阅读。我会尽力说服你。

为简洁起见,许多 Python 程序员将列表推导式简称为 listcomp ,将生成器表达式简称为 genexp。我也会使用这些术语。

列表推导式与可读性

做个测试:你觉得示例 2-1 和示例 2-2 哪个更容易阅读?

示例 2-1. 从字符串构建 Unicode 码位列表

python 复制代码
>>> symbols = '$¢£¥€¤'
>>> codes = []
>>> for symbol in symbols:
...     codes.append(ord(symbol))
...
>>> codes
[36, 162, 163, 165, 8364, 164]

示例 2-2. 使用列表推导式从字符串构建 Unicode 码位列表

python 复制代码
>>> symbols = '$¢£¥€¤'
>>> codes = [ord(symbol) for symbol in symbols]
>>> codes
[36, 162, 163, 165, 8364, 164]

任何一个懂一点 Python 的人都能读懂示例 2-1。然而,在了解列表推导式之后,我觉得示例 2-2 更具可读性,因为它的意图更加明确。

for 循环可以做很多不同的事情:扫描序列以计数或挑选元素、计算聚合值(总和、平均值)或其他任何任务。示例 2-1 是在构建一个列表。相比之下,列表推导式的目标更明确:它总是用于构建新列表。

当然,滥用列表推导式也可能写出难以理解的代码。我曾见过有人用列表推导式仅仅是为了重复执行一段代码以产生副作用。如果你不需要使用生成的列表,就不应该使用这种语法。另外,尽量保持简短。如果列表推导式超过两行,最好将其拆分或重写为普通的 for 循环。用你的最佳判断:对于 Python 和英语一样,清晰写作没有硬性规则。

语法提示

在 Python 代码中,方括号 []、花括号 {} 或圆括号 () 内部换行会被忽略。因此,你可以构建多行的列表、列表推导式、元组、字典等,而无需使用 \ 续行符(如果你不小心在 \ 后加空格,续行会失败)。此外,当使用这些分隔符定义逗号分隔的项目序列时,末尾的逗号会被忽略。所以,例如在编写多行列表字面量时,在最后一个项目后加上逗号是个好习惯,这样便于下一位编码者添加新项目,也能减少阅读 diff 时的干扰。

列表推导式和生成器表达式中的局部作用域

在 Python 3 中,列表推导式、生成器表达式以及它们的兄弟------集合推导式和字典推导式,都有一个局部作用域来保存 for 子句中赋值的变量。

然而,使用"海象运算符" := 赋值的变量在推导式或表达式返回后仍然可以访问------这与函数中的局部变量不同。PEP 572------赋值表达式规定,:= 的目标作用域是封闭函数,除非对该目标有 globalnonlocal 声明。

python 复制代码
>>> x = 'ABC'
>>> codes = [ord(x) for x in x]
>>> x
'ABC'
>>> codes
[65, 66, 67]
>>> codes = [last := ord(c) for c in x]
>>> last
67
>>> c
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'c' is not defined
  • x 没有被覆盖:它仍然绑定到 'ABC'
  • last 保留了下来。
  • c 消失了;它只存在于列表推导式内部。

列表推导式通过过滤和转换元素,从序列或其他可迭代对象构建列表。filtermap 内置函数组合起来也能做同样的事,但可读性较差,如下所示。

Listcomp 与 map/filter 的对比

列表推导式能完成 mapfilter 函数所做的一切,而且无需函数式编程中 lambda 的别扭写法。请看示例 2-3。

示例 2-3. 用列表推导式和 map/filter 组合构建相同的列表

python 复制代码
>>> symbols = '$¢£¥€¤'
>>> beyond_ascii = [ord(s) for s in symbols if ord(s) > 127]
>>> beyond_ascii
[162, 163, 165, 8364, 164]
>>> beyond_ascii = list(filter(lambda c: c > 127, map(ord, symbols)))
>>> beyond_ascii
[162, 163, 165, 8364, 164]

我曾经以为 mapfilter 比等效的列表推导式更快,但 Alex Martelli 指出并非如此------至少在前面的例子中不是。Fluent Python 代码仓库中的 02-array-seq/listcomp_speed.py 脚本是一个简单的速度测试,比较了列表推导式和 filter/map

关于 mapfilter,第 7 章会有更多讨论。现在,我们转向使用列表推导式计算笛卡尔积:一个包含从两个或多个列表中所有元素构建的元组的列表。

笛卡尔积

列表推导式可以从两个或多个可迭代对象的笛卡尔积构建列表。构成笛卡尔积的元素是由每个输入可迭代对象中的元素组成的元组。结果列表的长度等于输入可迭代对象长度的乘积。见图 2-3。

例如,假设你需要生成一个 T 恤列表,包含两种颜色和三种尺码。示例 2-4 展示了如何使用列表推导式生成该列表。结果有 6 个元素。

示例 2-4. 使用列表推导式生成笛卡尔积

python 复制代码
>>> colors = ['black', 'white']
>>> sizes = ['S', 'M', 'L']
>>> tshirts = [(color, size) for color in colors for size in sizes]
>>> tshirts
[('black', 'S'), ('black', 'M'), ('black', 'L'), ('white', 'S'), ('white', 'M'), ('white', 'L')]
>>> for color in colors:
...     for size in sizes:
...         print((color, size))
...
('black', 'S')
('black', 'M')
('black', 'L')
('white', 'S')
('white', 'M')
('white', 'L')
>>> tshirts = [(color, size) for size in sizes
...                     for color in colors]
>>> tshirts
[('black', 'S'), ('white', 'S'), ('black', 'M'), ('white', 'M'), ('black', 'L'), ('white', 'L')]
  • 生成一个按颜色、再按尺码排列的元组列表。
  • 注意结果列表的排列方式与列表推导式中 for 循环的嵌套顺序相同。
  • 要按尺码、再按颜色排列,只需重新排列 for 子句;给列表推导式加一个换行可以更容易看出结果的排序。

在第一章的示例 1-1 中,我使用以下表达式初始化一副牌,包含 4 种花色每种 13 种点数的 52 张牌,按花色、再按点数排序:

python 复制代码
self._cards = [Card(rank, suit) for suit in self.suits
                                for rank in self.ranks]

列表推导式只有一个功能:构建列表。要生成其他序列类型的数据,生成器表达式是更好的选择。下一节将简要介绍生成器表达式在构建非列表序列时的应用。

生成器表达式

要初始化元组、数组和其他类型的序列,你也可以从列表推导式开始,但生成器表达式(genexp)可以节省内存,因为它使用迭代器协议逐个产生元素,而不是构建整个列表再传给另一个构造函数。

生成器表达式使用与列表推导式相同的语法,但用圆括号而不是方括号括起来。

示例 2-5 展示了使用生成器表达式构建元组和数组的基本用法。

示例 2-5. 从生成器表达式初始化元组和数组

python 复制代码
>>> symbols = '$¢£¥€¤'
>>> tuple(ord(symbol) for symbol in symbols)
(36, 162, 163, 165, 8364, 164)
>>> import array
>>> array.array('I', (ord(symbol) for symbol in symbols))
array('I', [36, 162, 163, 165, 8364, 164])
  • 如果生成器表达式是函数调用中的唯一参数,则不需要重复括号。
  • array 构造函数需要两个参数,因此生成器表达式周围的括号是必须的。array 构造函数的第一个参数定义了数组中数字的存储类型,我们将在"数组"(第 59 页)中看到。

示例 2-6 使用带有笛卡尔积的生成器表达式,打印两种颜色、三种尺码的 T 恤名单。与示例 2-4 相比,这里从未在内存中构建包含六件 T 恤的列表:生成器表达式一次生成一个元素,直接供给 for 循环。如果笛卡尔积中使用的两个列表各有 1000 个元素,使用生成器表达式可以节省构建一个百万元素列表仅为了供给 for 循环的成本。

示例 2-6. 生成器表达式中的笛卡尔积

python 复制代码
>>> colors = ['black', 'white']
>>> sizes = ['S', 'M', 'L']
>>> for tshirt in (f'{c} {s}' for c in colors for s in sizes):
...     print(tshirt)
...
black S
black M
black L
white S
white M
white L
  • 生成器表达式逐个产生元素;本例中从未生成包含全部六种 T 恤变体的列表。

第 17 章将详细解释生成器的工作原理。这里只是展示如何使用生成器表达式来初始化非列表的序列,或者产生不需要保留在内存中的输出。

现在,我们转向 Python 中另一个重要的序列类型:元组。

元组不仅仅是不可变的列表

一些 Python 入门教材将元组介绍为"不可变的列表",但这低估了它。元组有双重职责:它们既可以用作不可变的列表,也可以用作没有字段名的记录。后一种用法有时被忽视,所以我们先从这里开始。

元组作为记录

元组保存记录:元组中的每个元素保存一个字段的数据,而元素的位置赋予其含义。

如果你仅仅把元组看作不可变的列表,那么元素的数量和顺序是否重要取决于上下文。但当你将元组用作字段的集合时,元素的数量通常是固定的,并且顺序总是很重要。

示例 2-7 展示了用作记录的元组。注意,在每种表达式中,对元组排序都会破坏信息,因为每个字段的含义由其位置决定。

示例 2-7. 用作记录的元组

python 复制代码
>>> lax_coordinates = (33.9425, -118.408056)
>>> city, year, pop, chg, area = ('Tokyo', 2003, 32_450, 0.66, 8014)
>>> traveler_ids = [('USA', '31195855'), ('BRA', 'CE342567'),
...                 ('ESP', 'XDA205856')]
>>> for passport in sorted(traveler_ids):
...     print('%s/%s' % passport)
...
BRA/CE342567
ESP/XDA205856
USA/31195855
>>> for country, _ in traveler_ids:
...     print(country)
...
USA
BRA
ESP
  • 洛杉矶国际机场的纬度和经度。
  • 关于东京的数据:名称、年份、人口(千)、人口变化(%)、面积(km²)。
  • 一个由 (country_code, passport_number) 形式的元组组成的列表。
  • 遍历列表时,passport 依次绑定到每个元组。
  • % 格式化运算符理解元组,并将每个元素视为一个单独的字段。
  • for 循环知道如何分别获取元组的元素------这称为"解包"。这里我们对第二个元素不感兴趣,所以将其赋给 _,一个虚拟变量。

通常,使用 _ 作为虚拟变量只是一种约定。它是一个奇怪但有效的变量名。然而,在 match/case 语句中,_ 是一个通配符,匹配任何值但不绑定到值。参见第 38 页的"序列模式匹配"。在 Python 控制台中,前面命令的结果会赋给 _ ------ 除非结果是 None

我们常常认为记录是具有命名字段的数据结构。第 5 章介绍了两种创建具名字段元组的方式。

但很多时候,仅仅为了命名字段而创建一个类是不必要的,特别是当你利用解包并避免使用索引访问字段时。在示例 2-7 中,我们通过一条语句将 ('Tokyo', 2003, 32_450, 0.66, 8014) 分别赋给了 cityyearpopchgarea。然后,% 运算符将 passport 元组中的每个元素赋给 print 参数中格式化字符串的对应位置。这是元组解包的两个例子。

Python 社区广泛使用"元组解包"这个术语,但"可迭代对象解包"也越来越流行,正如 PEP 3132------扩展的可迭代对象解包的标题所示。

第 35 页的"解包序列和可迭代对象"将更全面地介绍解包------不仅仅是元组,还包括一般的序列和可迭代对象。

现在,我们将 tuple 类视为 list 类的不可变变体。

元组作为不可变列表

Python 解释器和标准库广泛使用元组作为不可变列表,你也应该这样做。这带来两个关键好处:

  • 清晰性:当你在代码中看到元组时,你知道它的长度永远不会改变。
  • 性能:元组比相同长度的列表占用更少的内存,并且允许 Python 进行一些优化。

然而,请注意,元组的不可变性只适用于其中包含的引用 。元组中的引用不能被删除或替换。但如果其中一个引用指向一个可变对象,并且该对象被修改了,那么元组的值就会改变。下面这段代码通过创建两个元组 ab(初始相等)来说明这一点。图 2-4 展示了元组 b 在内存中的初始布局。

b 的最后一个元素被更改后,ba 变得不同:

python 复制代码
>>> a = (10, 'alpha', [1, 2])
>>> b = (10, 'alpha', [1, 2])
>>> a == b
True
>>> b[-1].append(99)
>>> a == b
False
>>> b
(10, 'alpha', [1, 2, 99])

包含可变元素的元组可能成为 bug 的根源。正如我们将在第 84 页的"什么是可哈希的"中看到的,一个对象只有在值永远不会改变时才可哈希。不可哈希的元组不能作为字典的键或集合的元素。

如果你想明确判断一个元组(或任何对象)是否具有固定值,可以使用内置函数 hash 创建一个像这样的函数:

python 复制代码
>>> def fixed(o):
...     try:
...         hash(o)
...     except TypeError:
...         return False
...     return True
...
>>> tf = (10, 'alpha', (1, 2))
>>> tm = (10, 'alpha', [1, 2])
>>> fixed(tf)
True
>>> fixed(tm)
False

我们将在第 207 页"元组的相对不可变性"中进一步探讨这个问题。尽管有这一注意事项,元组仍被广泛用作不可变列表。它们在性能上有一些优势,Python 核心开发者 Raymond Hettinger 在 Stack Overflow 回答"Are tuples more efficient than lists in Python?"中做了解释。总结一下,Hettinger 写道:

  • 为了求值一个元组字面量,Python 编译器会生成字节码,一次性地为元组常量构建;但对于列表字面量,生成的字节码会将每个元素作为单独的常量推入数据栈,然后再构建列表。
  • 给定一个元组 ttuple(t) 只是返回对同一个 t 的引用,无需复制。相反,给定一个列表 llist(l) 构造函数必须创建 l 的一个新副本。
  • 由于长度固定,元组实例会被分配恰好所需的内存空间。而列表实例则会分配额外的空间,以分摊未来 append 操作的成本。
  • 元组中的元素引用存储在一个数组中(位于元组结构体内部),而列表持有一个指向存储在别处的引用数组的指针。这种间接性是必要的,因为当列表增长超过当前分配的空间时,Python 需要重新分配引用数组来腾出空间。额外的间接性降低了 CPU 缓存的效率。

比较元组和列表的方法

当把元组用作列表的不可变变体时,了解它们 API 的相似度是很有帮助的。如表 2-1 所示,元组支持所有不涉及添加或删除元素的列表方法,但有一个例外------元组没有 __reversed__ 方法。然而,这只是为了优化;reversed(my_tuple) 没有它也能工作。

表 2-1. listtuple 中的方法和属性(为简洁起见,省略了由 object 实现的方法)

list tuple
s.__add__(s2)
s.__iadd__(s2)
s.append(e)
s.clear()
s.__contains__(e)
s.copy()
s.count(e)
s.__delitem__(p)
s.extend(it)
s.__getitem__(p)
s.__getnewargs__()
s.index(e)
s.insert(p, e)
s.__iter__()
s.__len__()
s.__mul__(n)
s.__imul__(n)
s.__rmul__(n)
s.pop([p])
s.remove(e)
s.reverse()
s.__reversed__()
s.__setitem__(p, e)
s.sort([key], [reverse])

¹ 反向运算符在第 16 章解释。

² 也可用于覆盖子序列。见第 50 页"给切片赋值"。

现在,我们转向 Python 编程中一个重要的主题:元组、列表和可迭代对象的解包。

解包序列和可迭代对象

解包很重要,因为它避免了使用索引提取序列元素时不必要的且容易出错的做法。同时,解包适用于任何可迭代对象作为数据源------包括不支持索引符号 [] 的迭代器。唯一的要求是,可迭代对象必须恰好为接收端的每个变量提供一个元素,除非你使用星号 * 来捕获多余的元素,如第 36 页"使用 * 获取多余的元素"所述。

解包最明显的形式是并行赋值:即将可迭代对象中的元素赋给一个元组变量。请看下面的例子:

python 复制代码
>>> lax_coordinates = (33.9425, -118.408056)
>>> latitude, longitude = lax_coordinates   # 解包
>>> latitude
33.9425
>>> longitude
-118.408056

解包的一个优雅应用是在不使用临时变量的情况下交换变量的值:

python 复制代码
>>> b, a = a, b

解包的另一个例子是在调用函数时给参数加上 * 前缀:

python 复制代码
>>> divmod(20, 8)
(2, 4)
>>> t = (20, 8)
>>> divmod(*t)
(2, 4)
>>> quotient, remainder = divmod(*t)
>>> quotient, remainder
(2, 4)

上面的代码展示了解包的另一个用途:允许函数以对调用者方便的方式返回多个值。另一个例子是 os.path.split() 函数,它从文件系统路径构建一个元组 (path, last_part)

python 复制代码
>>> import os
>>> _, filename = os.path.split('/home/luciano/.ssh/id_rsa.pub')
>>> filename
'id_rsa.pub'

另一种只使用部分元素进行解包的方式是使用 * 语法,我们马上就会看到。

使用 * 获取多余的元素

*args 定义函数参数以捕获任意多余参数是经典的 Python 特性。

在 Python 3 中,这个想法被扩展到了并行赋值中:

python 复制代码
>>> a, b, *rest = range(5)
>>> a, b, rest
(0, 1, [2, 3, 4])
>>> a, b, *rest = range(3)
>>> a, b, rest
(0, 1, [2])
>>> a, b, *rest = range(2)
>>> a, b, rest
(0, 1, [])

在并行赋值的上下文中,* 前缀可以恰好应用于一个变量,但它可以出现在任何位置:

python 复制代码
>>> a, *body, c, d = range(5)
>>> a, body, c, d
(0, [1, 2], 3, 4)
>>> *head, b, c, d = range(5)
>>> head, b, c, d
([0, 1], 2, 3, 4)

在函数调用和序列字面量中使用 * 解包

PEP 448------额外的解包泛化引入了更灵活的可迭代对象解包语法,在"Python 3.5 新特性"中进行了总结。

在函数调用中,我们可以多次使用 *

python 复制代码
>>> def fun(a, b, c, d, *rest):
...     return a, b, c, d, rest
...
>>> fun(*[1, 2], 3, *range(4, 7))
(1, 2, 3, 4, (5, 6))

* 也可以用于定义列表、元组或集合字面量,如下面来自"Python 3.5 新特性"的示例所示:

python 复制代码
>>> *range(4), 4
(0, 1, 2, 3, 4)
>>> [*range(4), 4]
[0, 1, 2, 3, 4]
>>> {*range(4), 4, *(5, 6, 7)}
{0, 1, 2, 3, 4, 5, 6, 7}

PEP 448 也为 ** 引入了类似的新语法,我们将在第 80 页"解包映射"中看到。

最后,元组解包的一个强大特性是它可以处理嵌套结构。

嵌套解包

解包的目标可以使用嵌套,例如 (a, b, (c, d))。如果值具有相同的嵌套结构,Python 会正确地执行。示例 2-8 展示了嵌套解包的实际应用。

示例 2-8. 解包嵌套元组以获取经度

python 复制代码
metro_areas = [
    ('Tokyo', 'JP', 36.933, (35.689722, 139.691667)),
    ('Delhi NCR', 'IN', 21.935, (28.613889, 77.208889)),
    ('Mexico City', 'MX', 20.142, (19.433333, -99.133333)),
    ('New York-Newark', 'US', 20.104, (40.808611, -74.020386)),
    ('São Paulo', 'BR', 19.649, (-23.547778, -46.635833)),
]

def main():
    print(f'{"":15} | {"latitude":>9} | {"longitude":>9}')
    for name, _, _, (lat, lon) in metro_areas:
        if lon <= 0:
            print(f'{name:15} | {lat:9.4f} | {lon:9.4f}')

if __name__ == '__main__':
    main()
  • 每个元组保存一个包含四个字段的记录,最后一个字段是一个坐标对。
  • 将最后一个字段赋给一个嵌套元组,从而解包坐标。
  • lon <= 0 条件筛选出西半球的城市。

示例 2-8 的输出为:

复制代码
               |  latitude |  longitude
Mexico City    |   19.4333 |  -99.1333
New York-Newark|   40.8086 |  -74.0204
São Paulo      |  -23.5478 |  -46.6358

解包赋值的目标也可以是列表,但好的用例很少。我知道的唯一一个例子是:如果你的数据库查询返回单条记录(例如 SQL 代码中有 LIMIT 1 子句),那么你可以用下面的代码进行解包,同时确保只有一个结果:

python 复制代码
>>> [record] = query_returning_single_row()

如果记录只有一个字段,你可以直接获取它,像这样:

python 复制代码
>>> [[field]] = query_returning_single_row_with_single_field()

这两个例子都可以使用元组来实现,但不要忘记单项元组必须使用尾随逗号的语法特点。因此,第一个目标应该是 (record,),第二个应该是 ((field,),)。在这两种情况下,如果你忘记逗号,就会得到一个无声的 bug。

现在,让我们学习模式匹配,它支持更强大的解包序列的方式。

序列模式匹配

Python 3.10 中最显著的新特性是 PEP 634------结构模式匹配:规范中提出的 match/case 语句带来的模式匹配。

Python 核心开发者 Carol Willing 在"Python 3.10 新特性"的"结构模式匹配"部分撰写了出色的模式匹配介绍。你可能想先阅读那个快速概览。在本书中,我选择根据模式类型将模式匹配的内容分散到不同章节:第 81 页的"映射模式匹配"、第 192 页的"类实例模式匹配"。一个扩展示例在第 669 页的"lis.py 中的模式匹配:案例研究"。

下面是一个处理序列的 match/case 的第一个例子。假设你正在设计一个机器人,它接受以单词和数字序列形式发送的命令,如 BEEPER 440 3。将其分割成部分并解析数字后,你会得到一个像 ['BEEPER', 440, 3] 的消息。你可以使用如下方法来处理这类消息:

示例 2-9. 一个虚构的 Robot 类中的方法

python 复制代码
def handle_command(self, message):
    match message:
        case ['BEEPER', frequency, times]:
            self.beep(times, frequency)
        case ['NECK', angle]:
            self.rotate_neck(angle)
        case ['LED', ident, intensity]:
            self.leds[ident].set_brightness(ident, intensity)
        case ['LED', ident, red, green, blue]:
            self.leds[ident].set_color(ident, red, green, blue)
        case _:
            raise InvalidCommand(message)
  • match 关键字后面的表达式是主题 。主题是 Python 尝试与每个 case 子句中的模式匹配的数据。
  • 此模式匹配任何包含三个元素的序列主题。第一个元素必须是字符串 'BEEPER'。第二个和第三个元素可以是任何值,它们将依次绑定到变量 frequencytimes
  • 此模式匹配任何包含两个元素、第一个是 'NECK' 的主题。
  • 这将匹配以 'LED' 开头的三个元素的主题。如果元素数量不匹配,Python 会进入下一个 case
  • 另一个以 'LED' 开头的序列模式,现在有五个元素------包括 'LED' 常量。
  • 这是默认情况。它将匹配任何未匹配先前模式的主题。_ 变量是特殊的,我们很快就会看到。

表面上,match/case 可能看起来像 C 语言中的 switch/case 语句------但这只是故事的一半。match 相对于 switch 的一个关键改进是解构------一种更高级的解包形式。"解构"是 Python 词汇中的一个新词,但在支持模式匹配的语言(如 Scala 和 Elixir)的文档中很常见。

作为解构的第一个例子,示例 2-10 展示了用 match/case 重写了示例 2-8 的一部分。

示例 2-10. 解构嵌套元组------需要 Python ≥ 3.10

python 复制代码
metro_areas = [
    ('Tokyo', 'JP', 36.933, (35.689722, 139.691667)),
    ('Delhi NCR', 'IN', 21.935, (28.613889, 77.208889)),
    ('Mexico City', 'MX', 20.142, (19.433333, -99.133333)),
    ('New York-Newark', 'US', 20.104, (40.808611, -74.020386)),
    ('São Paulo', 'BR', 19.649, (-23.547778, -46.635833)),
]

def main():
    print(f'{"":15} | {"latitude":>9} | {"longitude":>9}')
    for record in metro_areas:
        match record:
            case [name, _, _, (lat, lon)] if lon <= 0:
                print(f'{name:15} | {lat:9.4f} | {lon:9.4f}')
  • match 的主题是 record------即 metro_areas 中的每个元组。
  • 一个 case 子句有两个部分:模式和一个可选的带 if 关键字的守卫。
  • 一般来说,如果满足以下条件,序列模式匹配主题:
    1. 主题是一个序列;
    2. 主题和模式具有相同数量的元素;
    3. 每个对应的元素匹配,包括嵌套元素。

例如,示例 2-10 中的模式 [name, _, _, (lat, lon)] 匹配一个包含四个元素的序列,并且最后一个元素必须是一个包含两个元素的序列。

序列模式可以写成元组或列表,或任意嵌套的元组和列表的组合,但使用哪种语法没有区别:在序列模式中,方括号和圆括号含义相同。在示例 2-10 中,我将模式写成一个列表,里面嵌套了一个双元素元组,只是为了避免重复括号。

序列模式可以匹配 collections.abc.Sequence 的大多数实际或虚拟子类的实例,但 strbytesbytearray 除外。

match/case 的上下文中,strbytesbytearray 的实例不被视为序列。这些类型的匹配主题被视为"原子"值------就像整数 987 被当作一个值,而不是数字序列。将这三个类型视为序列可能因意外匹配而导致错误。如果你想把这类类型的对象作为序列主题处理,请在 match 子句中进行转换。例如,下面的 tuple(phone)

python 复制代码
match tuple(phone):
    case ['1', *rest]:  # 北美和加勒比地区
        ...
    case ['2', *rest]:  # 非洲和一些领土
        ...
    case ['3' | '4', *rest]:  # 欧洲
        ...

在标准库中,以下类型与序列模式兼容:

  • listmemoryviewarray.array
  • tuplerangecollections.deque

与解包不同,模式不会解构非序列的可迭代对象(如迭代器)。

_ 符号在模式中是特殊的:它匹配该位置的任何单个元素,但从不绑定到匹配元素的值。此外,_ 是唯一可以在模式中出现多次的变量。

你可以使用 as 关键字将模式的任何部分绑定到一个变量:

python 复制代码
case [name, _, _, (lat, lon) as coord]:

给定主题 ['Shanghai', 'CN', 24.9, (31.1, 121.3)],上面的模式将匹配,并设置以下变量:

变量 设置的值
name 'Shanghai'
lat 31.1
lon 121.3
coord (31.1, 121.3)

我们可以添加类型信息使模式更具体。例如,下面的模式匹配与前一个例子相同的嵌套序列结构,但第一个元素必须是 str 实例,双元素元组中的两个元素都必须是 float 实例:

python 复制代码
case [str(name), _, _, (float(lat), float(lon))]:

表达式 str(name)float(lat) 看起来像构造函数调用,我们通常会用来将 namelat 转换为 strfloat。但在模式的上下文中,这种语法执行的是运行时类型检查:上述模式将匹配一个四元素序列,其中第 0 个元素必须是 str,第 3 个元素必须是一对 float。此外,第 0 个元素中的 str 将绑定到 name 变量,第 3 个元素中的 float 将分别绑定到 latlon。因此,尽管 str(name) 借用了构造函数调用的语法,但在模式上下文中其语义完全不同。有关在模式中使用任意类的内容,请参见第 192 页"模式匹配类实例"。

另一方面,如果我们想匹配任何以 str 开头、以两个 float 的嵌套序列结尾的主题序列,我们可以写:

python 复制代码
case [str(name), *_, (float(lat), float(lon))]:

*_ 匹配任意数量的元素,而不将它们绑定到变量。使用 *extra 而不是 *_ 会将元素绑定到 extra 作为一个包含 0 个或多个元素的列表。

可选的守卫子句以 if 开头,仅当模式匹配时才求值,并且可以引用模式中绑定的变量,如示例 2-10 所示:

python 复制代码
match record:
    case [name, _, _, (lat, lon)] if lon <= 0:
        print(f'{name:15} | {lat:9.4f} | {lon:9.4f}')

print 语句的嵌套块仅在模式匹配守卫表达式为真时运行。

解构模式非常富有表现力,有时一个 match 带单个 case 就能让代码更简单。Guido van Rossum 有一个 case/match 示例集合,其中有一个标题为"一个非常深的可迭代对象和带提取的类型匹配"。

示例 2-10 并不是对示例 2-8 的改进。它只是一个对比两种实现相同功能方式的例子。下一个例子展示了模式匹配如何有助于编写清晰、简洁且有效的代码。

解释器中的序列模式匹配

斯坦福大学的 Peter Norvig 编写了 lis.py:一个用 132 行优美且可读的 Python 代码实现的 Scheme 方言(Lisp 语言的一个子集)的解释器。我获取了 Norvig 的 MIT 许可的源代码,并将其更新到 Python 3.10 以展示模式匹配。在本节中,我们将比较 Norvig 代码的一个关键部分------使用 if/elif 和解包------与使用 match/case 的重写版本。

lis.py 的两个主要函数是 parseevaluate。解析器接受 Scheme 的括号表达式并返回 Python 列表。这里有两个例子:

python 复制代码
>>> parse('(gcd 18 45)')
['gcd', 18, 45]
>>> parse('''
... (define double
...     (lambda (n)
...         (* n 2)))''')
['define', 'double', ['lambda', ['n'], ['*', 'n', 2]]]

求值器接受像这样的列表并执行它们。第一个例子是调用 gcd 函数,参数为 18 和 45。求值时,它计算参数的最大公约数:9。第二个例子定义了一个名为 double 的函数,带有一个参数 n。函数体是表达式 (* n 2)。在 Scheme 中调用函数的结果是函数体中最后一个表达式的值。

我们这里的重点是对序列进行解构,所以我不解释求值器的具体动作。请参阅第 669 页"lis.py 中的模式匹配:案例研究"以了解更多关于 lis.py 如何工作的信息。

示例 2-11 展示了 Norvig 的求值器,稍作修改,并省略了只显示序列模式的部分。

示例 2-11. 不使用 match/case 的模式匹配

python 复制代码
def evaluate(exp: Expression, env: Environment) -> Any:
    "Evaluate an expression in an environment."
    if isinstance(exp, Symbol):      # 变量引用
        return env[exp]
    # ... 省略行
    elif exp[0] == 'quote':          # (quote exp)
        (_, x) = exp
        return x
    elif exp[0] == 'if':             # (if test conseq alt)
        (_, test, consequence, alternative) = exp
        if evaluate(test, env):
            return evaluate(consequence, env)
        else:
            return evaluate(alternative, env)
    elif exp[0] == 'lambda':         # (lambda (parm...) body...)
        (_, parms, *body) = exp
        return Procedure(parms, body, env)
    elif exp[0] == 'define':
        (_, name, value_exp) = exp
        env[name] = evaluate(value_exp, env)
    # ... 更多省略行

注意每个 elif 子句如何检查列表的第一个元素,然后解包列表,忽略第一个元素。大量使用解包表明 Norvig 是模式匹配的爱好者,但他最初是为 Python 2 编写的代码(尽管现在可以在任何 Python 3 上运行)。

使用 Python ≥ 3.10 的 match/case,我们可以将 evaluate 重构为示例 2-12 所示。

示例 2-12. 使用 match/case 的模式匹配------需要 Python ≥ 3.10

python 复制代码
def evaluate(exp: Expression, env: Environment) -> Any:
    "Evaluate an expression in an environment."
    match exp:
        # ... 省略行
        case ['quote', x]:
            return x
        case ['if', test, consequence, alternative]:
            if evaluate(test, env):
                return evaluate(consequence, env)
            else:
                return evaluate(alternative, env)
        case ['lambda', [*parms], *body] if body:
            return Procedure(parms, body, env)
        case ['define', Symbol() as name, value_exp]:
            env[name] = evaluate(value_exp, env)
        # ... 更多省略行
        case _:
            raise SyntaxError(lispstr(exp))
  • 如果主题是以 'quote' 开头的两元素序列,则匹配。
  • 如果主题是以 'if' 开头的四元素序列,则匹配。
  • 如果主题是以 'lambda' 开头的三个或更多元素的序列,则匹配。守卫确保 body 非空。
  • 如果主题是以 'define' 开头的三元素序列,且第二个元素是 Symbol 的实例,则匹配。
  • 有一个捕获所有情况的 case 是好的实践。在此示例中,如果 exp 不匹配任何模式,则表达式格式错误,我引发 SyntaxError

如果没有捕获所有情况的 case,当主题不匹配任何 case 时,整个 match 语句什么都不做------这可能是静默失败。

Norvig 故意在 lis.py 中省略了错误检查以保持代码易于理解。通过模式匹配,我们可以添加更多检查,同时保持可读性。例如,在 'define' 模式中,原始代码不能确保 nameSymbol 的实例------那需要一个 if 块、一个 isinstance 调用和更多代码。示例 2-12 比示例 2-11 更短、更安全。

lambda 的替代模式

这是 Scheme 中 lambda 的语法,使用后缀 ... 表示元素可以出现零次或多次:

复制代码
(lambda (parms...) body1 body2...)

lambda 情况的一个简单模式是:

python 复制代码
case ['lambda', parms, *body] if body:

然而,这将匹配 parms 位置上的任何值,包括下面无效主题中的第一个 'x'

python 复制代码
['lambda', 'x', ['*', 'x', 2]]

Scheme 中 lambda 关键字后面的嵌套列表保存函数的形参名称,即使只有一个元素,它也必须是列表。如果函数不带参数------就像 Python 的 random.random()------它可以是一个空列表。

在示例 2-12 中,我使用嵌套序列模式使 'lambda' 模式更安全:

python 复制代码
case ['lambda', [*parms], *body] if body:
    return Procedure(parms, body, env)

在序列模式中,* 在每个序列中只能出现一次。这里我们有两个序列:外部和内部。

parms 周围添加 [*] 使模式看起来更像它所处理的 Scheme 语法,并为我们提供了额外的结构检查。

函数定义的快捷语法

Scheme 有一种替代的 define 语法,用于创建命名函数而不使用嵌套的 lambda。语法如下:

复制代码
(define (name parm...) body1 body2...)

define 关键字后面跟着一个列表,包含新函数的名称和零个或多个参数名。该列表之后是包含一个或多个表达式的函数体。

match 中添加这两行即可实现:

python 复制代码
case ['define', [Symbol() as name, *parms], *body] if body:
    env[name] = Procedure(parms, body, env)

我会把这个 case 放在示例 2-12 中另一个 define case 之后。在这个例子中,两个 define case 的顺序无关紧要,因为没有主题能同时匹配这两个模式:在原始的 define case 中第二个元素必须是 Symbol,而在用于函数定义的 define 快捷方式中,第二个元素必须是以 Symbol 开头的序列。

现在想想,如果没有示例 2-11 中模式匹配的帮助,添加对第二种 define 语法的支持需要多少工作。match 语句做的比 C 类语言中的 switch 多得多。

模式匹配是声明式编程的一个例子:代码描述了"什么"你要匹配,而不是"如何"匹配。代码的形状遵循数据的形状,如表 2-2 所示。

表 2-2. 一些 Scheme 语法形式及其处理它们的序列模式

Scheme 语法 序列模式
(quote exp) ['quote', exp]
(if test conseq alt) ['if', test, conseq, alt]
(lambda (parms...) body1 body2...) ['lambda', [*parms], *body] if body
(define name exp) ['define', Symbol() as name, exp]
(define (name parms...) body1 body2...) ['define', [Symbol() as name, *parms], *body] if body

我希望这次使用模式匹配重构 Norvig 的 evaluate 让你相信 match/case 可以使你的代码更可读、更安全。

我们将在第 669 页"lis.py 中的模式匹配:案例研究"中看到更多 lis.py 的内容,届时我们将回顾 evaluate 中的完整 match/case 示例。如果你想了解更多关于 Norvig 的 lis.py,请阅读他精彩的文章《(How to Write a (Lisp) Interpreter (in Python))》。

至此,我们结束了对序列解包、解构和模式匹配的第一次巡游。我们将在后面的章节中介绍其他类型的模式。

每个 Python 程序员都知道可以使用 s[a:b] 语法对序列进行切片。我们现在转向一些鲜为人知的切片事实。

切片

Python 中 listtuplestr 以及所有序列类型的一个共同特性是支持切片操作,其强大程度超出大多数人的认识。

在本节中,我们将描述这些高级切片形式的用法。在用户定义的类中实现它们将在第 12 章介绍,这符合我们在本书这部分介绍现成可用的类,而在第三部分创建新类的理念。

为什么切片和区间排除最后一个元素

在切片和区间中排除最后一个元素的 Python 风格约定,与 Python、C 及许多其他语言中使用的基于零的索引配合得很好。该约定的一些便利特性包括:

  • 当只给出停止位置时,很容易看出切片或区间的长度:range(3)my_list[:3] 都产生三个元素。
  • 当给出开始和停止时,很容易计算切片或区间的长度:只需 stop - start
  • 很容易在任意索引 x 处将一个序列分成两部分而不重叠:只需获取 my_list[:x]my_list[x:]。例如:
python 复制代码
>>> l = [10, 20, 30, 40, 50, 60]
>>> l[:2]   # 在2处分割
[10, 20]
>>> l[2:]
[30, 40, 50, 60]
>>> l[:3]   # 在3处分割
[10, 20, 30]
>>> l[3:]
[40, 50, 60]

关于这个约定最好的论据是由荷兰计算机科学家 Edsger W. Dijkstra 撰写的(见第 71 页"进一步阅读"中的最后一个参考文献)。

现在让我们仔细看看 Python 如何解释切片表示法。

切片对象

这虽然不是秘密,但值得重复以防万一:s[a:b:c] 可用于指定步长 c,使结果切片跳过元素。步长也可以是负数,反向返回元素。三个例子可以说明这一点:

python 复制代码
>>> s = 'bicycle'
>>> s[::3]
'bye'
>>> s[::-1]
'elcycib'
>>> s[::-2]
'eccb'

另一个例子在第一章中,我们使用 deck[12::13] 获取未洗牌牌堆中的所有 A:

python 复制代码
>>> deck[12::13]
[Card(rank='A', suit='spades'), Card(rank='A', suit='diamonds'), Card(rank='A', suit='clubs'), Card(rank='A', suit='hearts')]

表示法 a:b:c 仅在用作索引或下标运算符时在 [] 内部有效,它产生一个 切片对象slice(a, b, c)。正如我们将在第 404 页"切片如何工作"中看到的,为了求值表达式 seq[start:stop:step],Python 调用 seq.__getitem__(slice(start, stop, step))。即使你不实现自己的序列类型,了解切片对象也很有用,因为它允许你为切片命名,就像电子表格允许命名单元格区域一样。

假设你需要解析像示例 2-13 这样的平面文件数据。你可以为切片命名,而不是用硬编码的切片填充代码。看看这在 for 循环末尾的可读性有多高。

示例 2-13. 来自平面文件发票的行项目

python 复制代码
>>> invoice = """
... 0.....6.................................40........52...55........
... 1909  Pimoroni PiBrella                    $17.50    3    $52.50
... 1489  6mm Tactile Switch x20               $4.95     2    $9.90
... 1510  Panavise Jr. - PV-201                $28.00    1    $28.00
... 1601  PiTFT Mini Kit 320x240               $34.95    1    $34.95
... """
>>> SKU = slice(0, 6)
>>> DESCRIPTION = slice(6, 40)
>>> UNIT_PRICE = slice(40, 52)
>>> QUANTITY = slice(52, 55)
>>> ITEM_TOTAL = slice(55, None)
>>> line_items = invoice.split('\n')[2:]
>>> for item in line_items:
...     print(item[UNIT_PRICE], item[DESCRIPTION])
...
$17.50   Pimoroni PiBrella
$4.95    6mm Tactile Switch x20
$28.00   Panavise Jr. - PV-201
$34.95   PiTFT Mini Kit 320x240

在讨论创建自己的集合时(第 403 页"Vector 第二版:一个可切片的序列"),我们会回到切片对象。与此同时,从用户的角度看,切片还包括其他特性,如多维切片和省略号 ... 表示法。继续阅读。

多维切片和省略号

[] 操作符也可以接受多个由逗号分隔的索引或切片。处理 [] 操作符的特殊方法 __getitem____setitem__ 只是将 a[i, j] 中的索引作为一个元组接收。换句话说,为了求值 a[i, j],Python 调用 a.__getitem__((i, j))

例如,这被用于外部的 NumPy 包中,其中二维 numpy.ndarray 的元素可以使用语法 a[i, j] 获取,二维切片可以使用像 a[m:n, k:l] 这样的表达式获得。本章后面的示例 2-22 展示了这种表示法的使用。

除了 memoryview,Python 中的内置序列类型都是一维的,因此它们只支持一个索引或切片,而不支持它们的元组。

省略号------由三个英文句点 ... 写成,而不是 Unicode U+2026 字符------被 Python 解析器识别为一个标记。它是 Ellipsis 对象的别名,ellipsis 类的唯一实例。因此,它可以作为参数传递给函数,并作为切片规范的一部分,如 f(a, ..., z)a[i:...]。NumPy 在对多维数组进行切片时使用 ... 作为快捷方式;例如,如果 x 是一个四维数组,那么 x[i, ...] 就是 x[i, :, :, :] 的快捷方式。有关更多信息,请参阅"NumPy quickstart"。

在撰写本文时,我不知道 Python 标准库中有使用 Ellipsis 或多维索引和切片的例子。如果你发现了一处,请告诉我。这些语法特性存在是为了支持用户定义类型和像 NumPy 这样的扩展。

切片不仅用于从序列中提取信息;它们还可以用来就地修改可变序列------即无需从头重建它们。

给切片赋值

可变序列可以使用切片表示法在赋值语句的左侧或作为 del 语句的目标进行就地移植、切除或其他修改。接下来的几个例子展示了这种表示法的强大之处:

python 复制代码
>>> l = list(range(10))
>>> l
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> l[2:5] = [20, 30]
>>> l
[0, 1, 20, 30, 5, 6, 7, 8, 9]
>>> del l[5:7]
>>> l
[0, 1, 20, 30, 5, 8, 9]
>>> l[3::2] = [11, 22]
>>> l
[0, 1, 20, 11, 5, 22, 9]
>>> l[2:5] = 100
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: can only assign an iterable
>>> l[2:5] = [100]
>>> l
[0, 1, 100, 22, 9]
  • 当赋值的目标是切片时,右侧必须是一个可迭代对象,即使它只有一个元素。

每个程序员都知道拼接是序列的常见操作。Python 入门教程解释了使用 +* 的目的,但它们的工作方式有一些微妙的细节,我们接下来将介绍。

对序列使用 +*

Python 程序员期望序列支持 +*。通常,+ 的两个操作数必须是相同的序列类型,并且都不会被修改,但会创建一个相同类型的新序列作为拼接的结果。

要拼接同一个序列的多个副本,可以将其乘以一个整数。同样,会创建一个新序列:

python 复制代码
>>> l = [1, 2, 3]
>>> l * 5
[1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3]
>>> 5 * 'abcd'
'abcdabcdabcdabcdabcd'

+* 都总是创建新对象,并且从不改变它们的操作数。

小心像 a * n 这样的表达式,当 a 是一个包含可变元素的序列时,结果可能会让你惊讶。例如,尝试将列表的列表初始化为 my_list = [[]] * 3 将得到一个包含三个对同一个内部列表的引用的列表,这很可能不是你想要的结果。

下一节将介绍使用 * 初始化列表的列表时的陷阱。

构建列表的列表

有时我们需要用一定数量的嵌套列表来初始化一个列表------例如,将学生分配到团队列表中,或表示游戏棋盘上的方格。最佳方法是使用列表推导式,如示例 2-14 所示。

示例 2-14. 一个包含三个长度为 3 的列表的列表可以表示一个井字棋盘

python 复制代码
>>> board = [['_'] * 3 for i in range(3)]
>>> board
[['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']]
>>> board[1][2] = 'X'
>>> board
[['_', '_', '_'], ['_', '_', 'X'], ['_', '_', '_']]
  • 创建一个包含三个列表的列表,每个列表有三个元素。检查结构。
  • 在第 1 行,第 2 列放置一个标记,然后检查结果。

一个诱人但错误的快捷方式是像示例 2-15 那样做。

示例 2-15. 一个包含三个对同一个列表的引用的列表是无用的

python 复制代码
>>> weird_board = [['_'] * 3] * 3
>>> weird_board
[['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']]
>>> weird_board[1][2] = 'O'
>>> weird_board
[['_', '_', 'O'], ['_', '_', 'O'], ['_', '_', 'O']]
  • 外部列表由三个对同一个内部列表的引用组成。当它保持不变时,一切看起来都正常。
  • 在第 1 行,第 2 列放置一个标记,揭示了所有行都是引用同一对象的别名。

示例 2-15 的问题在于,它本质上等同于以下代码:

python 复制代码
row = ['_'] * 3
board = []
for i in range(3):
    board.append(row)
  • 同一个 row 被三次追加到 board

另一方面,示例 2-14 的列表推导式等价于以下代码:

python 复制代码
>>> board = []
>>> for i in range(3):
...     row = ['_'] * 3
...     board.append(row)
...
>>> board
[['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']]
>>> board[2][0] = 'X'
>>> board
[['_', '_', '_'], ['_', '_', '_'], ['X', '_', '_']]
  • 每次迭代构建一个新的 row 并将其追加到 board
  • 只有第 2 行被更改,符合预期。

如果你对本节中的问题或解决方案不清楚,请放松。第 6 章旨在阐明引用和可变对象的机制及陷阱。

到目前为止,我们已经讨论了在序列上使用普通的 +* 运算符,但还有 +=*= 运算符,它们根据目标序列的可变性产生非常不同的结果。下一节将解释其工作原理。

序列的增强赋值

增强赋值运算符 +=*= 的行为有很大不同,具体取决于第一个操作数。为简化讨论,我们首先关注增强加法 +=,但这些概念也适用于 *= 和其他增强赋值运算符。

使 += 工作的特殊方法是 __iadd__(用于"就地加法")。然而,如果 __iadd__ 未实现,Python 会回退到调用 __add__。考虑这个简单的表达式:

python 复制代码
>>> a += b

如果 a 实现了 __iadd__,则会调用它。对于可变序列(例如 listbytearrayarray.array),a 将被就地改变(即效果类似于 a.extend(b))。然而,当 a 没有实现 __iadd__ 时,表达式 a += b 的效果等同于 a = a + b:首先求值表达式 a + b,产生一个新对象,然后将其绑定到 a。换句话说,绑定到 a 的对象标识可能改变,也可能不变,这取决于 __iadd__ 的可用性。

一般来说,对于可变序列,很可能实现了 __iadd__,因此 += 是就地执行的。对于不可变序列,显然无法做到这一点。

我刚刚写的关于 += 的内容也适用于 *=,它通过 __imul__ 实现。__iadd____imul__ 特殊方法将在第 16 章讨论。下面是一个 *= 在可变序列和不可变序列上的演示:

python 复制代码
>>> l = [1, 2, 3]
>>> id(l)
4311953800
>>> l *= 2
>>> l
[1, 2, 3, 1, 2, 3]
>>> id(l)
4311953800
>>> t = (1, 2, 3)
>>> id(t)
4312681568
>>> t *= 2
>>> id(t)
4301348296
  • 初始列表的 ID。
  • 乘法之后,列表是同一个对象,追加了新元素。
  • 初始元组的 ID。
  • 乘法之后,创建了一个新元组。

重复拼接不可变序列是低效的,因为解释器不是简单地追加新元素,而是必须复制整个目标序列来创建一个包含新元素的新序列。

我们已经看到了 += 的常见用例。下一节展示了一个有趣的边界情况,突显了"不可变"在元组上下文中的真正含义。

+= 赋值的谜题

尝试在不使用控制台的情况下回答:求值示例 2-16 中的两个表达式的结果是什么?

示例 2-16. 一个谜题

python 复制代码
>>> t = (1, 2, [30, 40])
>>> t[2] += [50, 60]

接下来会发生什么?选择最佳答案:

A. t 变成 (1, 2, [30, 40, 50, 60])

B. 抛出 TypeError,消息为 'tuple' object does not support item assignment

C. 两者都不是。

D. 两者 A 和 B。

当我看到这个时,我很确定答案是 B,但实际上它是 D,"两者 A 和 B"!示例 2-17 是 Python 3.9 控制台的实际输出。

示例 2-17. 意外的结果:t[2] 被更改,并且引发了异常

python 复制代码
>>> t = (1, 2, [30, 40])
>>> t[2] += [50, 60]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
>>> t
(1, 2, [30, 40, 50, 60])

Online Python Tutor 是一个很棒的在线工具,可以可视化 Python 的详细工作方式。图 2-5 是显示示例 2-17 中元组 t 初始和最终状态的两个屏幕截图的组合。

如果你查看 Python 为表达式 s[a] += b 生成的字节码(示例 2-18),就会清楚这为何发生。

示例 2-18. 表达式 s[a] += b 的字节码

python 复制代码
>>> dis.dis('s[a] += b')
  1           0 LOAD_NAME                0 (s)
              3 LOAD_NAME                1 (a)
              6 DUP_TOP_TWO
              7 BINARY_SUBSCR
              8 LOAD_NAME                2 (b)
             11 INPLACE_ADD
             12 ROT_THREE
             13 STORE_SUBSCR
             14 LOAD_CONST               0 (None)
             17 RETURN_VALUE
  1. s[a] 的值放在 TOS(栈顶)。
  2. 执行 TOS += b。如果 TOS 引用了一个可变对象(在示例 2-17 中是一个列表),这将成功。
  3. 赋值 s[a] = TOS。如果 s 是不可变的(示例 2-17 中的元组 t),这将失败。

这是一个相当边缘的情况------在我使用 Python 的 20 年中,我从未见过这种奇怪的行为真正困扰到任何人。

我从中学到三个教训:

  • 避免将可变元素放入元组中。
  • 增强赋值不是原子操作------我们刚刚看到它在完成部分工作后抛出了异常。
  • 检查 Python 字节码并不太难,并且有助于了解底层发生了什么。

在见证了使用 +* 进行拼接的微妙之处后,我们可以将话题转向序列的另一个基本操作:排序。

list.sort 与内置函数 sorted

list.sort 方法就地排序列表------即不制作副本。它返回 None 以提醒我们它改变了接收者,并且不创建新列表。这是一个重要的 Python API 约定:就地改变对象的函数或方法应该返回 None,以向调用者明确接收者已被更改,并且没有创建新对象。例如,在 random.shuffle(s) 函数中也可以看到类似的行为,它就地洗牌可变序列 s,并返回 None

返回 None 以表示就地更改的约定有一个缺点:我们不能级联调用这些方法。相比之下,返回新对象的方法(例如所有 str 方法)可以以流畅接口风格级联。有关此主题的更多描述,请参阅维基百科的"Fluent interface"条目。

相比之下,内置函数 sorted 创建一个新列表并返回它。它接受任何可迭代对象作为参数,包括不可变序列和生成器(见第 17 章)。无论给 sorted 的可迭代对象类型如何,它总是返回一个新创建的列表。

list.sortsorted 都接受两个可选的、仅限关键字的参数:

reverse

如果为 True,则以降序返回元素(即通过反转元素的比较)。默认为 False

key

一个单参数函数,将应用于每个元素以生成其排序键。例如,对字符串列表进行排序时,可以使用 key=str.lower 执行不区分大小写的排序,使用 key=len 将按字符长度排序。默认为恒等函数(即比较元素本身)。

你也可以将可选的关键字参数 key 用于内置函数 min()max(),以及标准库中的其他函数(例如 itertools.groupby()heapq.nlargest())。

下面是一些示例,以阐明这些函数和关键字参数的用法。这些示例还演示了 Python 的排序算法是稳定的(即它保留比较相等的元素的相对顺序):

python 复制代码
>>> fruits = ['grape', 'raspberry', 'apple', 'banana']
>>> sorted(fruits)
['apple', 'banana', 'grape', 'raspberry']
>>> fruits
['grape', 'raspberry', 'apple', 'banana']
>>> sorted(fruits, reverse=True)
['raspberry', 'grape', 'banana', 'apple']
>>> sorted(fruits, key=len)
['grape', 'apple', 'banana', 'raspberry']
>>> sorted(fruits, key=len, reverse=True)
['raspberry', 'banana', 'grape', 'apple']
>>> fruits
['grape', 'raspberry', 'apple', 'banana']
>>> fruits.sort()
>>> fruits
['apple', 'banana', 'grape', 'raspberry']
  • 这会按字母顺序生成一个新的字符串列表。
  • 检查原始列表,发现它没有改变。
  • 这是之前的"字母"顺序的反向。
  • 一个新的字符串列表,现在按长度排序。因为排序算法是稳定的,长度均为 5 的 "grape" 和 "apple" 保持原始顺序。
  • 这些是按长度降序排列的字符串。它不是前一个结果的反向,因为排序是稳定的,所以 "grape" 仍然出现在 "apple" 之前。
  • 到目前为止,原始 fruits 列表的顺序没有改变。
  • 这会在原地排序列表,并返回 None(控制台省略了)。
  • 现在 fruits 已排序。

默认情况下,Python 按字符代码的字典序对字符串进行排序。这意味着 ASCII 大写字母会排在小写字母之前,非 ASCII 字符不太可能以人类期望的方式排序。第 148 页"排序 Unicode 文本"介绍了按人类期望的方式正确排序文本的方法。

一旦序列被排序,就可以非常高效地进行搜索。Python 标准库的 bisect 模块已经提供了二分搜索算法。该模块还包括 bisect.insort 函数,你可以用它来确保你的已排序序列保持排序状态。你可以在 fluentpython.com 配套网站的"Managing Ordered Sequences with Bisect"一文中找到带有插图的 bisect 模块介绍。

到目前为止,本章中我们看到的大部分内容都适用于一般的序列,而不仅仅是列表或元组。Python 程序员有时会过度使用 list 类型,因为它非常方便------我知道我也这样做过。例如,如果你正在处理大型的数字列表,你应该考虑使用数组。本章的剩余部分将介绍列表和元组的替代方案。

当列表不是答案时

list 类型灵活且易于使用,但根据具体需求,有更好的选择。例如,当你需要处理数百万个浮点值时,array 可以节省大量内存。另一方面,如果你不断地从列表的两端添加和删除元素,那么 deque(双端队列)是更高效的 FIFO 数据结构。

如果你的代码经常检查一个元素是否在集合中(例如 item in my_collection),考虑使用 set 作为 my_collection,特别是当它包含大量元素时。set 针对快速成员检查进行了优化。它们也是可迭代的,但不是序列,因为集合元素的顺序未指定。我们将在第 3 章介绍它们。

在本章的剩余部分,我们将讨论在许多情况下可以替代列表的可变序列类型,从数组开始。

数组

如果一个列表只包含数字,array.array 是一个更高效的替代品。数组支持所有可变序列操作(包括 .pop.insert.extend),以及用于快速加载和保存的附加方法,如 .frombytes.tofile

Python 数组与 C 数组一样精简。如图 2-1 所示,一个浮点值数组不包含完整的 float 实例,而只包含表示其机器值的打包字节------类似于 C 语言中的 double 数组。创建数组时,你需要提供一个类型码 ,一个用于确定存储每个数组元素所使用的底层 C 类型的字母。例如,b 是 C 称为 signed char 的类型码,一个范围在 -128 到 127 的整数。如果你创建一个 array('b'),那么每个元素将存储在一个字节中并解释为整数。对于大型数字序列,这可以节省大量内存。而且 Python 不允许你放入任何不符合数组类型的数字。

示例 2-19 展示了创建、保存和加载一个包含 1000 万个随机浮点数的数组。

示例 2-19. 创建、保存和加载一个大的浮点数数组

python 复制代码
>>> from array import array
>>> from random import random
>>> floats = array('d', (random() for i in range(10**7)))
>>> floats[-1]
0.07802343889111107
>>> fp = open('floats.bin', 'wb')
>>> floats.tofile(fp)
>>> fp.close()
>>> floats2 = array('d')
>>> fp = open('floats.bin', 'rb')
>>> floats2.fromfile(fp, 10**7)
>>> fp.close()
>>> floats2[-1]
0.07802343889111107
>>> floats2 == floats
True
  • 导入 array 类型。
  • 从任何可迭代对象创建一个双精度浮点数数组(类型码 'd')------这里是一个生成器表达式。
  • 检查数组中的最后一个数字。
  • 将数组保存到二进制文件。
  • 创建一个空的双精度浮点数数组。
  • 从二进制文件中读取 1000 万个数字。
  • 检查数组中的最后一个数字。
  • 验证两个数组的内容是否匹配。

如你所见,array.tofilearray.fromfile 易于使用。如果你尝试这个例子,你会注意到它们也非常快。一个快速实验表明,从使用 array.tofile 创建的二进制文件中加载 1000 万个双精度浮点数大约需要 0.1 秒。这比从文本文件中读取数字(涉及用 float 内置函数解析每一行)快了近 60 倍。使用 array.tofile 保存比将每个浮点数作为一行写入文本文件快了大约 7 倍。此外,包含 1000 万个双精度浮点数的二进制文件大小为 80,000,000 字节(每个双精度 8 字节,零开销),而相同数据的文本文件有 181,515,739 字节。

对于表示二进制数据的数字数组(例如光栅图像),Python 有 bytesbytearray 类型,将在第 4 章讨论。

我们以表 2-3 结束本节,比较 listarray.array 的特性。

表 2-3. listarray 中的方法和属性(为简洁起见,省略了已弃用的数组方法和由 object 实现的方法)

list array
s.__add__(s2)
s.__iadd__(s2)
s.append(e)
s.byteswap()
s.clear()
s.__contains__(e)
s.copy()
s.__copy__()
s.count(e)
s.__deepcopy__()
s.__delitem__(p)
s.extend(it)
s.frombytes(b)
s.fromfile(f, n)
s.fromlist(l)
s.__getitem__(p)
s.index(e)
s.insert(p, e)
s.itemsize
s.__iter__()
s.__len__()
s.__mul__(n)
s.__imul__(n)
s.__rmul__(n)
s.pop([p])
s.remove(e)
s.reverse()
s.__reversed__()
s.__setitem__(p, e)
s.sort([key], [reverse])
s.tobytes()
s.tofile(f)
s.tolist()
s.typecode

¹ 反向运算符在第 16 章解释。

截至 Python 3.10,array 类型没有像 list.sort() 那样的就地排序方法。如果你需要对数组排序,可以使用内置函数 sorted 重建数组:

python 复制代码
a = array.array(a.typecode, sorted(a))

要在保持已排序数组有序的同时向其添加元素,请使用 bisect.insort 函数。

如果你做了很多数组工作,却不知道 memoryview,那你可错过了好东西。请看下一个主题。

内存视图

内置的 memoryview 类是一个共享内存的序列类型,它允许你处理数组的切片而无需复制字节。它受到 NumPy 库的启发(我们将在后面的"NumPy"第 64 页讨论)。NumPy 的主要作者 Travis Oliphant 回答"什么时候应该使用 memoryview?"时这样说:

一个 memoryview 本质上是 Python 本身中的一个广义 NumPy 数组结构(没有数学部分)。它允许你在数据结构(如 PIL 图像、SQLite 数据库、NumPy 数组等)之间共享内存,而无需先复制。这对于大数据集非常重要。

使用类似于 array 模块的表示法,memoryview.cast 方法允许你改变读取或写入多个字节的方式,而不移动位。memoryview.cast 返回另一个 memoryview 对象,始终共享相同的内存。

示例 2-20 展示了如何在同一个 6 字节数组上创建替代视图,将其作为 2×3 矩阵或 3×2 矩阵进行操作。

示例 2-20. 将 6 字节内存作为 1×6、2×3 和 3×2 视图处理

python 复制代码
>>> from array import array
>>> octets = array('B', range(6))
>>> m1 = memoryview(octets)
>>> m1.tolist()
[0, 1, 2, 3, 4, 5]
>>> m2 = m1.cast('B', [2, 3])
>>> m2.tolist()
[[0, 1, 2], [3, 4, 5]]
>>> m3 = m1.cast('B', [3, 2])
>>> m3.tolist()
[[0, 1], [2, 3], [4, 5]]
>>> m2[1,1] = 22
>>> m3[1,1] = 33
>>> octets
array('B', [0, 1, 2, 33, 22, 5])
  • 构建包含 6 字节的数组(类型码 'B')。
  • 从该数组构建 memoryview,然后将其导出为列表。
  • 从先前内存视图构建新的内存视图,但现在有 2 行 3 列。
  • 另一个内存视图,现在有 3 行 2 列。
  • m2 的第 1 行第 1 列将字节覆盖为 22。
  • m3 的第 1 行第 1 列将字节覆盖为 33。
  • 显示原始数组,证明内存在 octetsm1m2m3 之间是共享的。

memoryview 的强大力量也可用于破坏。示例 2-21 展示了如何通过修改一个字节来改变一个 16 位整数数组中的元素。

示例 2-21. 通过修改一个字节来改变 16 位整数数组元素的值

python 复制代码
>>> numbers = array.array('h', [-2, -1, 0, 1, 2])
>>> memv = memoryview(numbers)
>>> len(memv)
5
>>> memv[0]
-2
>>> memv_oct = memv.cast('B')
>>> memv_oct.tolist()
[254, 255, 255, 255, 0, 0, 1, 0, 2, 0]
>>> memv_oct[5] = 4
>>> numbers
array('h', [-2, -1, 1024, 1, 2])
  • 从包含 5 个 16 位有符号整数(类型码 'h')的数组构建 memoryview
  • memv 看到数组中的相同 5 个元素。
  • 通过将 memv 的元素转换为字节(类型码 'B')创建 memv_oct
  • memv_oct 的元素导出为包含 10 字节的列表以供检查。
  • 将值 4 赋给字节偏移量 5。
  • 注意 numbers 的变化:2 字节无符号整数的最高有效字节中的 4 是 1024。

你可以在 fluentpython.com 上找到一个使用 struct 包检查 memoryview 的例子:"Parsing binary records with struct"。

与此同时,如果你正在进行数组的高级数值处理,你应该使用 NumPy 库。我们马上简要了解一下。

NumPy

在本书中,我强调 Python 标准库中已有的内容,以便你能充分利用它。但 NumPy 太棒了,值得绕道介绍。

对于高级数组和矩阵操作,NumPy 是 Python 成为科学计算应用主流的原因。NumPy 实现了多维同构数组和矩阵类型,不仅可以存放数字,还可以存放用户定义的记录,并提供了高效的逐元素操作。

SciPy 是一个构建在 NumPy 之上的库,提供了许多科学计算算法,包括线性代数、数值计算和统计学。SciPy 快速可靠,因为它利用了 Netlib 存储库中广泛使用的 C 和 Fortran 代码库。换句话说,SciPy 为科学家提供了两全其美的优势:交互式提示符和高层级的 Python API,以及用 C 和 Fortran 优化的工业级数值计算函数。

作为一个非常简短的 NumPy 演示,示例 2-22 展示了二维数组的一些基本操作。

示例 2-22. numpy.ndarray 中行和列的基本操作

python 复制代码
>>> import numpy as np
>>> a = np.arange(12)
>>> a
array([ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11])
>>> type(a)
<class 'numpy.ndarray'>
>>> a.shape
(12,)
>>> a.shape = 3, 4
>>> a
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])
>>> a[2]
array([ 8,  9, 10, 11])
>>> a[2, 1]
9
>>> a[:, 1]
array([1, 5, 9])
>>> a.transpose()
array([[ 0,  4,  8],
       [ 1,  5,  9],
       [ 2,  6, 10],
       [ 3,  7, 11]])
  • 安装后导入 NumPy(它不在 Python 标准库中)。按照惯例,numpy 作为 np 导入。
  • 构建并检查一个包含整数 0 到 11 的 numpy.ndarray
  • 检查数组的维度:这是一个一维的 12 元素数组。
  • 改变数组的形状,添加一个维度,然后检查结果。
  • 获取索引 2 的行。
  • 获取索引 2, 1 的元素。
  • 获取索引 1 的列。
  • 通过转置(交换行和列)创建一个新数组。

NumPy 还支持用于加载、保存和操作 numpy.ndarray 所有元素的高级操作:

python 复制代码
>>> import numpy
>>> floats = numpy.loadtxt('floats-10M-lines.txt')
>>> floats[-3:]
array([ 3016362.69195522,   535281.10514262,  4566560.44373946])
>>> floats *= .5
>>> floats[-3:]
array([ 1508181.34597761,   267640.55257131,  2283280.22186973])
>>> from time import perf_counter as pc
>>> t0 = pc(); floats /= 3; pc() - t0
0.03690556302899495
>>> numpy.save('floats-10M', floats)
>>> floats2 = numpy.load('floats-10M.npy', 'r+')
>>> floats2 *= 6
>>> floats2[-3:]
memmap([ 3016362.69195522,   535281.10514262,  4566560.44373946])
  • 从文本文件加载 1000 万个浮点数。
  • 使用序列切片表示法检查最后三个数字。
  • floats 数组中的每个元素乘以 0.5,并再次检查最后三个元素。
  • 导入高分辨率性能测量计时器(自 Python 3.3 起可用)。
  • 将每个元素除以 3;对 1000 万个浮点数的耗时不到 40 毫秒。
  • 将数组保存为 .npy 二进制文件。
  • 将数据作为内存映射文件加载到另一个数组中;即使数组不能完全放入内存,这也允许高效处理数组的切片。
  • 将每个元素乘以 6 后检查最后三个元素。

这只是一个开胃菜。

NumPy 和 SciPy 是强大的库,并且是其他优秀工具的基础,例如 Pandas------它实现了可以容纳非数字数据的有效数组类型,并提供了许多不同格式的导入/导出功能,如 .csv.xls、SQL 转储、HDF5 等------以及 scikit-learn,目前最广泛使用的机器学习工具集。大多数 NumPy 和 SciPy 函数都是用 C 或 C++ 实现的,并且可以利用所有 CPU 核心,因为它们释放了 Python 的 GIL(全局解释器锁)。Dask 项目支持将 NumPy、Pandas 和 scikit-learn 处理并行化到机器集群上。这些包值得用整本书来介绍。这不是那些书之一。但是,如果没有至少快速浏览一下 NumPy 数组,任何关于 Python 序列的概述都是不完整的。

在了解了扁平序列------标准数组和 NumPy 数组之后,我们现在转向一组完全不同的替代普通旧列表的方案:队列。

Deque 和其他队列

.append.pop 方法使列表可以用作栈或队列(如果使用 .append.pop(0),则获得 FIFO 行为)。但在列表头部(索引 0 端)插入和删除代价很高,因为整个列表必须在内存中移动。

collections.deque 类是一个线程安全的双端队列,旨在从两端快速插入和删除。如果你需要保留一个"最近看到的项目"列表或类似的东西,它也是一个不错的选择,因为 deque 可以是有界的 ------即创建时指定一个固定的最大长度。如果有界 deque 已满,当你添加一个新项目时,它会丢弃另一端的项目。示例 2-23 展示了在 deque 上执行的一些典型操作。

示例 2-23. 使用 deque

python 复制代码
>>> from collections import deque
>>> dq = deque(range(10), maxlen=10)
>>> dq
deque([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], maxlen=10)
>>> dq.rotate(3)
>>> dq
deque([7, 8, 9, 0, 1, 2, 3, 4, 5, 6], maxlen=10)
>>> dq.rotate(-4)
>>> dq
deque([1, 2, 3, 4, 5, 6, 7, 8, 9, 0], maxlen=10)
>>> dq.appendleft(-1)
>>> dq
deque([-1, 1, 2, 3, 4, 5, 6, 7, 8, 9], maxlen=10)
>>> dq.extend([11, 22, 33])
>>> dq
deque([3, 4, 5, 6, 7, 8, 9, 11, 22, 33], maxlen=10)
>>> dq.extendleft([10, 20, 30, 40])
>>> dq
deque([40, 30, 20, 10, 3, 4, 5, 6, 7, 8], maxlen=10)
  • 可选的 maxlen 参数设置了此 deque 实例中允许的最大项目数;这设置了一个只读的 maxlen 实例属性。
  • 使用 n > 0 旋转会从右端取出项目并将其前置到左端;当 n < 0 时,项目从左端取出并追加到右端。
  • 向已满的 deque 追加元素会丢弃另一端的元素;注意下一行中 0 被丢弃了。
  • 向右添加三个元素会推出最左边的 -112
  • 注意 extendleft(iter) 通过将可迭代参数的每个后续元素追加到 deque 的左端来工作,因此元素的最终位置是反转的。

表 2-4 比较了 listdeque 特有的方法(删除了也出现在 object 中的方法)。

请注意,deque 实现了大多数 list 的方法,并添加了一些特定于其设计的方法,如 popleftrotate。但有一个隐藏的成本:从 deque 中间删除元素不那么快。它真正优化的是从两端追加和弹出。

appendpopleft 操作是原子的,因此 deque 可以安全地用作多线程应用程序中的 FIFO 队列,而无需锁。

表 2-4. listdeque 中实现的方法(为简洁起见,省略了也由 object 实现的方法)

list deque
s.__add__(s2)
s.__iadd__(s2)
s.append(e)
s.appendleft(e)
s.clear()
s.__contains__(e)
s.copy()
s.__copy__()
s.count(e)
s.__delitem__(p)
s.extend(i)
s.extendleft(i)
s.__getitem__(p)
s.index(e)
s.insert(p, e)
s.__iter__()
s.__len__()
s.__mul__(n)
s.__imul__(n)
s.__rmul__(n)
s.pop()
s.popleft()
s.remove(e)
s.reverse()
s.__reversed__()
s.rotate(n)
s.__setitem__(p, e)
s.sort([key], [reverse])

¹ 反向运算符在第 16 章解释。

² a_list.pop(p) 允许从位置 p 删除,但 deque 不支持该选项。

除了 deque,其他 Python 标准库包也实现了队列:

queue

这提供了同步(即线程安全)的类 SimpleQueueQueueLifoQueuePriorityQueue。它们可用于线程之间的安全通信。除了 SimpleQueue 之外,都可以通过向构造函数提供大于 0 的 maxsize 参数来设置界限。但是,它们不像 deque 那样丢弃元素以腾出空间。相反,当队列满时,插入新元素会阻塞------即它等待直到另一个线程通过从队列中取出元素来腾出空间,这对于限制活动线程的数量很有用。

multiprocessing

实现自己的无界 SimpleQueue 和有界 Queue,与 queue 包中的非常相似,但设计用于进程间通信。提供了一个专门的 multiprocessing.JoinableQueue 用于任务管理。

asyncio

提供 QueueLifoQueuePriorityQueueJoinableQueue,其 API 受 queuemultiprocessing 模块中的类启发,但适用于异步编程中的任务管理。

heapq

与前三个模块不同,heapq 没有实现队列类,但提供了诸如 heappushheappop 之类的函数,让你可以使用可变序列作为堆队列或优先级队列。

这就结束了对 list 类型替代方案的概述,也结束了对序列类型的一般探索------除了 str 和二进制序列的细节,它们有自己的章节(第 4 章)。

本章小结

掌握标准库的序列类型是编写简洁、有效和 Pythonic 代码的先决条件。

Python 序列通常被分类为可变或不可变,但考虑另一个轴也很有用:扁平序列和容器序列。前者更紧凑、更快、更易于使用,但仅限于存储原子数据,如数字、字符和字节。容器序列更灵活,但当它们包含可变对象时可能会让你惊讶,因此你需要小心地在嵌套数据结构中正确使用它们。

不幸的是,Python 没有万无一失的不可变容器序列类型:即使是"不可变"的元组,当它们包含像列表或用户定义对象这样的可变元素时,其值也可能改变。

列表推导式和生成器表达式是构建和初始化序列的强大表示法。如果你还不熟悉它们,花时间掌握它们的基本用法。这不难,很快你就会着迷。

Python 中的元组扮演两个角色:作为没有字段名的记录,以及作为不可变列表。当使用元组作为不可变列表时,请记住,只有当元组中的所有元素也是不可变时,元组的值才保证是固定的。对元组调用 hash(t) 是断言其值固定的快速方法。如果 t 包含可变元素,则会引发 TypeError

当元组用作记录时,元组解包是提取元组字段最安全、最可读的方式。除了元组,* 在许多上下文中也适用于列表和可迭代对象,其中一些用例在 Python 3.5 中随 PEP 448------额外的解包泛化出现。Python 3.10 引入了 match/case 模式匹配,支持更强大的解包,称为解构。

序列切片是 Python 最受喜爱的语法特性之一,其强大程度超出许多人的认识。多维切片和省略号 ... 表示法,如 NumPy 中使用的那样,也可能被用户定义的序列支持。给切片赋值是编辑可变序列的一种极具表达力的方式。

seq * n 这样的重复拼接很方便,并且小心使用可以用于初始化包含不可变元素的列表的列表。增强赋值 +=*= 在可变和不可变序列上的行为不同。在后一种情况下,这些运算符必然构建新序列。但如果目标序列是可变的,它通常是就地改变的------但并不总是,这取决于序列的实现方式。

排序方法 sort 和内置函数 sorted 易于使用且灵活,这要归功于可选参数 key:一个计算排序标准的函数。顺便说一下,key 也可以用于内置函数 minmax

除了列表和元组,Python 标准库还提供了 array.array。虽然 NumPy 和 SciPy 不是标准库的一部分,但如果你对大型数据集进行任何类型的数值处理,即使只学习这些库的一小部分也能让你受益匪浅。

最后,我们访问了多才多艺且线程安全的 collections.deque,在表 2-4 中将其 API 与 list 进行了比较,并提到了标准库中的其他队列实现。

进一步阅读

David Beazley 和 Brian K. Jones 合著的《Python Cookbook》第 3 版(O'Reilly)的第 1 章"数据结构"有许多关于序列的食谱,包括"食谱 1.11. 命名切片",我从中学到了将切片赋给变量以提高可读性的技巧,如我们的示例 2-13 所示。

《Python Cookbook》第 2 版是为 Python 2.4 编写的,但其大部分代码适用于 Python 3,并且第 5 章和第 6 章中的许多食谱都涉及序列。该书由 Alex Martelli、Anna Ravenscroft 和 David Ascher 编辑,并包含数十位 Pythonistas 的贡献。第 3 版从头重写,更侧重于语言的语义------特别是 Python 3 中发生的变化------而旧版则强调实用性(即如何将语言应用于实际问题)。尽管第 2 版中的某些解决方案不再是最好方法,但我诚实认为手头同时拥有两个版本的《Python Cookbook》是值得的。

官方的 Python"Sorting HOW TO"提供了许多使用 sortedlist.sort 的高级技巧示例。

PEP 3132------扩展的可迭代对象解包是阅读关于在并行赋值左侧使用 *extra 语法新规范的权威来源。如果你想一睹 Python 的演变,请查看问题跟踪器中的"Missing *-unpacking generalizations"问题,它提出了对可迭代对象解包表示法的增强建议。PEP 448------额外的解包泛化是该问题讨论的结果。

正如我在第 38 页的"序列模式匹配"中提到的,Carol Willing 在"Python 3.10 新特性"中的"结构模式匹配"部分是对这一重大新特性的大约 1400 字(当 Firefox 从 HTML 生成 PDF 时不到 5 页)的精彩介绍。PEP 636------结构模式匹配:教程也很好,但更长。同一份 PEP 636 包括"附录 A------快速介绍"。它比 Willing 的介绍短,因为它省略了关于为什么模式匹配对你有好处的高级考虑。如果你需要更多论据来说服自己或他人模式匹配对 Python 有好处,请阅读 22 页的 PEP 635------结构模式匹配:动机与理由。

Eli Bendersky 的博客文章"Less copies in Python with the buffer protocol and memoryviews"包含一个关于 memoryview 的简短教程。

市面上有许多关于 NumPy 的书,很多书名中没有提到"NumPy"。两个例子是 Jake VanderPlas 的开放获取《Python Data Science Handbook》和 Wes McKinney 的《Python for Data Analysis》第 2 版。

"NumPy 就是关于向量化的。"这是 Nicolas P. Rougier 的开放获取书《From Python to NumPy》的开篇句。向量化操作将数学函数应用于数组的所有元素,而无需用 Python 编写的显式循环。它们可以并行操作,使用现代 CPU 中的特殊向量指令,利用多核或委托给 GPU,具体取决于库。Rougier 书中的第一个例子展示了将一个使用生成器方法的漂亮 Pythonic 类重构为一个调用几个 NumPy 向量函数的精简函数后,速度提升了 500 倍。

要学习如何使用 deque(以及其他集合),请参阅 Python 文档中的"Container datatypes"中的示例和实践食谱。

对 Python 在区间和切片中排除最后一个元素约定最有力的辩护是由 Edsger W. Dijkstra 本人撰写的,在一份名为"Why Numbering Should Start at Zero"的简短备忘录中。该备忘录的主题是数学表示法,但与 Python 相关,因为 Dijkstra 用严谨和幽默解释了为什么像 2, 3, ..., 12 这样的序列应始终表示为 2 ≤ i < 13。所有其他合理的约定都被驳斥,让每个用户选择约定的想法也是如此。标题指的是基于零的索引,但备忘录实际上是关于为什么 'ABCDE'[1:3] 意味着 'BC' 而不是 'BCD',以及为什么写 range(2, 13) 来产生 2, 3, 4, ..., 12 是完全合理的。顺便说一下,这份备忘录是手写的,但很美观且完全可读。Dijkstra 的笔迹非常清晰,以至于有人根据他的笔记创建了一种字体。

讨论区

元组的本质

2012 年,我在 PyCon US 上展示了一张关于 ABC 语言的海报。在创建 Python 之前,Guido van Rossum 曾参与 ABC 解释器的工作,所以他来看了我的海报。我们聊了包括 ABC 的 compounds 在内的许多事情,它们显然是 Python 元组的前身。Compounds 也支持并行赋值,并用作字典(或用 ABC 的说法是表)中的复合键。然而,compounds 不是序列。它们不可迭代,你不能通过索引检索字段,更不用说切片了。你要么将 compound 作为一个整体处理,要么使用并行赋值提取各个字段,仅此而已。

我告诉 Guido,这些限制使得 compounds 的主要用途非常明确:它们只是没有字段名的记录。他的回答是:"让元组表现得像序列是一个 hack。"

这说明了使 Python 比 ABC 更实用、更成功的务实方法。从语言实现者的角度来看,让元组表现得像序列代价很小。结果,元组作为记录的主要用例不那么明显,但我们获得了不可变列表------即使它们的类型没有像 frozenlist 那样清晰地命名。

扁平序列 vs 容器序列

为了突出序列类型的不同内存模型,我使用了术语容器序列扁平序列。"容器"这个词来自"数据模型"文档:

一些对象包含对其他对象的引用;这些被称为容器。

我使用"容器序列"这个术语是为了具体化,因为 Python 中有一些容器不是序列,如 dictset。容器序列可以嵌套,因为它们可以包含任何类型的对象,包括它们自己的类型。

另一方面,扁平序列是不能嵌套的序列类型,因为它们只包含简单的原子类型,如整数、浮点数或字符。

我采用了"扁平序列"这个术语,因为我需要与"容器序列"相对的东西。

尽管官方文档中先前使用了"容器"这个词,但 collections.abc 中有一个名为 Container 的抽象类。那个 ABC 只有一个方法 __contains__------in 运算符背后的特殊方法。这意味着字符串和数组,它们不是传统意义上的容器,是 Container 的虚拟子类,因为它们实现了 __contains__。这只是一个例子,说明人类使用同一个词来表达不同的事物。在本书中,我将用小写字母的 "container" 表示"一个包含对其他对象的引用的对象",用大写开头且在等宽字体中的 Container 表示 collections.abc.Container

混合类型的列表

Python 入门教材强调列表可以包含混合类型的对象,但在实践中,这个特性不是很有用:我们把元素放在列表中以备后续处理,这意味着所有元素应该至少支持某个共同的操作(即它们应该都"呱呱叫",无论它们是不是 100% 的鸭子)。例如,你无法在 Python 3 中对列表进行排序,除非其中的元素是可比较的:

python 复制代码
>>> l = [28, 14, '28', 5, '9', '1', 0, 6, '23', 19]
>>> sorted(l)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unorderable types: str() < int()

与列表不同,元组通常包含不同类型的元素。这很自然:如果元组中的每个元素是一个字段,那么每个字段可能有不同的类型。

key 很出色

list.sortsortedmaxmin 的可选参数 key 是一个很棒的想法。其他语言迫使你提供一个双参数比较函数,比如 Python 2 中已弃用的 cmp(a, b) 函数。使用 key 更简单、更高效。它更简单,因为你只需要定义一个单参数函数来检索或计算你想要的排序标准;这比编写一个返回 -1、0、1 的双参数函数更容易。它也更高


示例代码

以下是根据第二章"序列的数组"整理的所有代码示例,按章节顺序组织,形成可直接运行的 Python 脚本。示例中有些需要用户交互(如 input())或依赖外部文件(如 floats-10M-lines.txt),已做注释说明。请将脚本保存为 .py 文件并在 Python 3.10+ 环境中运行。

python 复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
第二章《序列的数组》代码示例集
涵盖了列表推导式、生成器表达式、元组解包、模式匹配、切片、数组、内存视图、NumPy、deque等。
需要 Python 3.10 或更高版本以支持模式匹配(match/case)。
"""

import sys
if sys.version_info < (3, 10):
    print("本示例需要 Python 3.10+ 以支持模式匹配功能。")
    sys.exit(1)

# 导入后续示例所需的模块
import array
import random
import collections
import os
import dis
from collections import deque
import numpy as np   # 需要安装 numpy
from array import array
from random import random
from collections import namedtuple
from typing import Any, Union
import math

print("=" * 60)
print("第二章 代码示例")
print("=" * 60)

# ----------------------------------------------------------------------
# 1. 列表推导式 (List Comprehensions)
# ----------------------------------------------------------------------
print("\n--- 1. 列表推导式与生成器表达式 ---")

# 示例2-1 和 2-2: 从字符串构建Unicode码位
symbols = '$¢£¥€¤'
codes_for = []
for symbol in symbols:
    codes_for.append(ord(symbol))
print("for循环构建:", codes_for)

codes_listcomp = [ord(s) for s in symbols]
print("列表推导式构建:", codes_listcomp)

# 示例2-3: listcomp vs map/filter
beyond_ascii_comp = [ord(s) for s in symbols if ord(s) > 127]
print("beyond ASCII (listcomp):", beyond_ascii_comp)
beyond_ascii_map = list(filter(lambda c: c > 127, map(ord, symbols)))
print("beyond ASCII (map+filter):", beyond_ascii_map)

# 示例2-4: 笛卡尔积
colors = ['black', 'white']
sizes = ['S', 'M', 'L']
tshirts = [(color, size) for color in colors for size in sizes]
print("T恤列表:", tshirts)

# 示例2-6: 生成器表达式实现笛卡尔积 (不创建列表)
print("生成器笛卡尔积:")
for tshirt in (f'{c} {s}' for c in colors for s in sizes):
    print(f"  {tshirt}")

# ----------------------------------------------------------------------
# 2. 元组解包 (Unpacking)
# ----------------------------------------------------------------------
print("\n--- 2. 元组解包 ---")

# 基础解包
lax_coordinates = (33.9425, -118.408056)
latitude, longitude = lax_coordinates
print(f"LAX坐标: 纬度={latitude}, 经度={longitude}")

# 交换变量
a, b = 10, 20
b, a = a, b
print(f"交换后: a={a}, b={b}")

# 函数返回多值的解包
t = (20, 8)
q, r = divmod(*t)
print(f"divmod(20,8) = ({q}, {r})")

# 使用 * 收集多余项
a, b, *rest = range(5)
print(f"a={a}, b={b}, rest={rest}")
a, *body, c, d = range(5)
print(f"a={a}, body={body}, c={c}, d={d}")

# 嵌套解包
metro_areas = [
    ('Tokyo', 'JP', 36.933, (35.689722, 139.691667)),
    ('Delhi NCR', 'IN', 21.935, (28.613889, 77.208889)),
    ('Mexico City', 'MX', 20.142, (19.433333, -99.133333)),
    ('New York-Newark', 'US', 20.104, (40.808611, -74.020386)),
    ('São Paulo', 'BR', 19.649, (-23.547778, -46.635833)),
]
print("西半球城市:")
for name, _, _, (lat, lon) in metro_areas:
    if lon <= 0:
        print(f"  {name:15} | 纬度 {lat:9.4f} | 经度 {lon:9.4f}")

# ----------------------------------------------------------------------
# 3. 模式匹配 (Pattern Matching) - Python 3.10+
# ----------------------------------------------------------------------
print("\n--- 3. 模式匹配 with match/case ---")

# 重新实现上面的 metro_areas 示例
print("使用 match/case 的西半球城市:")
for record in metro_areas:
    match record:
        case [name, _, _, (lat, lon)] if lon <= 0:
            print(f"  {name:15} | 纬度 {lat:9.4f} | 经度 {lon:9.4f}")

# 模拟 lis.py 中的 evaluate 函数片段 (简化版)
# 定义 Symbol 类型
class Symbol(str):
    pass

def evaluate(exp: Union[list, tuple, Symbol], env: dict) -> Any:
    """简化的求值器,展示模式匹配"""
    match exp:
        case ['quote', x]:
            return x
        case ['if', test, consequence, alternative]:
            return consequence if evaluate(test, env) else alternative
        case ['lambda', [*parms], *body] if body:
            return f"Procedure(parms={parms}, body={body})"
        case ['define', Symbol() as name, value_exp]:
            env[name] = evaluate(value_exp, env)
            return None
        case _:
            raise SyntaxError(f"未知语法: {exp}")

env = {}
evaluate(['define', Symbol('double'), ['lambda', ['n'], ['*', 'n', 2]]], env)
print("环境中的定义:", env)

# ----------------------------------------------------------------------
# 4. 切片 (Slicing)
# ----------------------------------------------------------------------
print("\n--- 4. 切片高级用法 ---")

# 步长和反向
s = 'bicycle'
print(f"'{s}'[::3] = '{s[::3]}'")
print(f"'{s}'[::-1] = '{s[::-1]}'")
print(f"'{s}'[::-2] = '{s[::-2]}'")

# 命名切片 (示例2-13)
invoice = """
0.....6.................................40........52...55........
1909  Pimoroni PiBrella                    $17.50    3    $52.50
1489  6mm Tactile Switch x20               $4.95     2    $9.90
1510  Panavise Jr. - PV-201                $28.00    1    $28.00
1601  PiTFT Mini Kit 320x240               $34.95    1    $34.95
"""
SKU = slice(0, 6)
DESCRIPTION = slice(6, 40)
UNIT_PRICE = slice(40, 52)
QUANTITY = slice(52, 55)
ITEM_TOTAL = slice(55, None)
line_items = invoice.split('\n')[2:]
print("切片命名示例:")
for item in line_items:
    if item.strip():  # 跳过空行
        print(f"  单价: {item[UNIT_PRICE]}, 描述: {item[DESCRIPTION]}")

# 切片赋值
l = list(range(10))
print(f"原列表: {l}")
l[2:5] = [20, 30]
print(f"l[2:5]=[20,30] -> {l}")
del l[5:7]
print(f"del l[5:7] -> {l}")
l[3::2] = [11, 22]
print(f"l[3::2]=[11,22] -> {l}")
l[2:5] = [100]   # 必须赋可迭代对象
print(f"l[2:5]=[100] -> {l}")

# ----------------------------------------------------------------------
# 5. 列表的列表构建陷阱
# ----------------------------------------------------------------------
print("\n--- 5. 构建列表的列表 (正确 vs 错误) ---")

# 正确方式: 列表推导式
board = [['_'] * 3 for i in range(3)]
board[1][2] = 'X'
print("正确方式构建的棋盘:")
for row in board:
    print("  ", row)

# 错误方式: 乘法的陷阱
weird_board = [['_'] * 3] * 3
weird_board[1][2] = 'O'
print("错误方式 (三个引用相同子列表):")
for row in weird_board:
    print("  ", row)

# ----------------------------------------------------------------------
# 6. 增强赋值 += 和 *= 的行为
# ----------------------------------------------------------------------
print("\n--- 6. 增强赋值 += 和 *= ---")

# 可变序列 (list)
l = [1, 2, 3]
print(f"初始列表: {l}, id={id(l)}")
l *= 2
print(f"l *= 2 后: {l}, id={id(l)} (相同对象)")

# 不可变序列 (tuple)
t = (1, 2, 3)
print(f"初始元组: {t}, id={id(t)}")
t *= 2
print(f"t *= 2 后: {t}, id={id(t)} (新对象)")

# 谜题: 元组包含可变列表
print("\n谜题: 元组中包含列表的 += 操作")
t_puzzle = (1, 2, [30, 40])
print(f"原元组: {t_puzzle}")
try:
    t_puzzle[2] += [50, 60]
except TypeError as e:
    print(f"捕获异常: {e}")
print(f"执行后元组: {t_puzzle}  (注意列表被修改了!)")

# ----------------------------------------------------------------------
# 7. 排序: list.sort vs sorted
# ----------------------------------------------------------------------
print("\n--- 7. 排序 ---")
fruits = ['grape', 'raspberry', 'apple', 'banana']
print("原始:", fruits)
print("sorted 返回新列表:", sorted(fruits))
print("sorted(..., reverse=True):", sorted(fruits, reverse=True))
print("sorted(..., key=len):", sorted(fruits, key=len))
print("sorted(..., key=len, reverse=True):", sorted(fruits, key=len, reverse=True))
fruits.sort()
print("原地 fruits.sort():", fruits)

# ----------------------------------------------------------------------
# 8. Array 数组
# ----------------------------------------------------------------------
print("\n--- 8. array 数组 ---")
# 创建和保存/加载大数组 (仅演示小规模)
floats = array('d', (random() for i in range(10)))  # 只取10个作为演示
print("随机浮点数组:", floats)
# 保存到二进制文件
with open('floats_demo.bin', 'wb') as f:
    floats.tofile(f)
# 加载回来
floats2 = array('d')
with open('floats_demo.bin', 'rb') as f:
    floats2.fromfile(f, len(floats))
print("从文件加载的数组:", floats2)
print("两个数组相等:", floats2 == floats)
os.remove('floats_demo.bin')  # 清理

# ----------------------------------------------------------------------
# 9. 内存视图 memoryview
# ----------------------------------------------------------------------
print("\n--- 9. memoryview 示例 ---")
octets = array('B', range(6))
m1 = memoryview(octets)
print("原始数组:", octets.tolist())
m2 = m1.cast('B', [2, 3])   # 2x3 视图
m3 = m1.cast('B', [3, 2])   # 3x2 视图
m2[1, 1] = 22
m3[1, 1] = 33
print("修改后数组:", octets.tolist())

# 修改16位整数数组的一个字节
numbers = array('h', [-2, -1, 0, 1, 2])
memv = memoryview(numbers)
memv_oct = memv.cast('B')
memv_oct[5] = 4   # 改变第6个字节
print("原始 numbers:", array('h', [-2, -1, 0, 1, 2]))
print("修改一个字节后:", numbers)

# ----------------------------------------------------------------------
# 10. NumPy 简短示例 (需要安装 numpy)
# ----------------------------------------------------------------------
print("\n--- 10. NumPy 示例 (需要 numpy 库) ---")
try:
    a = np.arange(12)
    print("arange(12):", a)
    a.shape = 3, 4
    print("变形为 3x4:\n", a)
    print("第二行:", a[2])
    print("索引 [2,1]:", a[2, 1])
    print("第一列:", a[:, 1])
    print("转置:\n", a.transpose())
except ImportError:
    print("numpy 未安装,跳过此示例。")

# ----------------------------------------------------------------------
# 11. Deque 双端队列
# ----------------------------------------------------------------------
print("\n--- 11. collections.deque ---")
dq = deque(range(10), maxlen=10)
print("初始:", dq)
dq.rotate(3)
print("rotate(3):", dq)
dq.rotate(-4)
print("rotate(-4):", dq)
dq.appendleft(-1)
print("appendleft(-1):", dq)
dq.extend([11, 22, 33])
print("extend([11,22,33]):", dq)
dq.extendleft([10, 20, 30, 40])
print("extendleft([10,20,30,40]):", dq)

print("\n=== 所有示例运行完成 ===")

说明:

  1. 环境要求 :Python 3.10+(为了模式匹配),并且需要安装 numpy(可选,若不安装会跳过相关示例)。
  2. 文件依赖 :示例中的 floats-10M-lines.txt 未创建,故数组例子使用了小规模数据演示,避免生成大文件。读者可自行生成大文件测试性能。
  3. 运行方式 :将代码保存为 .py 文件后直接运行。输出会显示每个示例的执行结果。
  4. 关于谜题 :元组 += 的示例会抛出 TypeError,但同时列表已被修改,代码中捕获异常并显示了最终元组内容,以展示书中的"两者 A 和 B"现象。
  5. 模式匹配 :模拟了 lis.pyevaluate 函数的一个简化版本,用于演示 match/case 对序列的匹配能力。

如果你希望将所有示例分开为独立的脚本文件,也可以提出,我可以帮你拆分。

相关推荐
byoass1 小时前
企业云盘API集成指南:如何与CI/CD流水线打通
网络·安全·ci/cd·云计算
独自破碎E1 小时前
解决 Windows 虚拟内存迁移失败的全过程实录
windows
2401_831419441 小时前
Python分类汇总怎么做_Crosstab交叉表与多条件联合频数频率统计
jvm·数据库·python
LucaJu1 小时前
DeepAgents 人工介入实战|LangGraph 实现 Agent 高危工具人工审批
python·langchain·agent·langgraph·deepagents
L1624761 小时前
临时拉高 CPU 利用率(防缩容)操作全总结(linux和windows系统)
linux·运维·windows
2301_787312431 小时前
Go语言怎么用channel做信号通知_Go语言channel信号模式教程【完整】
jvm·数据库·python
大卡片2 小时前
TCP、IP和TFTP协议
服务器·网络·tcp/ip
2301_818008442 小时前
如何删除ASM中的数据文件_ALTER DISKGROUP DROP FILE彻底清除
jvm·数据库·python
汽车仪器仪表相关领域2 小时前
Kvaser Memorator Professional HS/LS:高速 + 低速双通道 CAN 总线记录仪,跨系统诊断的专业级解决方案
网络·人工智能·功能测试·测试工具·安全·压力测试