更佳的循环:深入探讨 Python 中的迭代

深入了解 Python 的 for 循环,看看它们在底层是如何工作的,以及为什么它们以这种方式工作。
396 位读者喜欢这篇文章。
hello kitty pez dispenser with hand and candy

Deborah Austin。CC BY-SA 4.0

Python 的 for 循环的工作方式与其他语言中的 for 循环不同。在本文中,我们将深入了解 Python 的 for 循环,看看它们在底层是如何工作的,以及为什么它们以这种方式工作。

循环陷阱

我们将从了解一些“陷阱”开始我们的旅程。在我们学习了 Python 中的循环工作原理之后,我们将再次审视这些陷阱并解释正在发生的事情。

陷阱 1:循环两次

假设我们有一个数字列表和一个生成器,它将为我们提供这些数字的平方

>>> numbers = [1, 2, 3, 5, 7]
>>> squares = (n**2 for n in numbers)

我们可以将生成器对象传递给 tuple 构造函数,以从中创建一个元组

>>> tuple(squares)
(1, 4, 9, 25, 49)

如果我们然后取相同的生成器对象并将其传递给 sum 函数,我们可能会期望得到这些数字的总和,这将是 88。

>>> sum(squares)
0

但我们得到的是 0

陷阱 2:包含性检查

让我们使用相同的数字列表和相同的生成器对象

>>> numbers = [1, 2, 3, 5, 7]
>>> squares = (n**2 for n in numbers)

如果我们询问 9 是否在我们的 squares 生成器中,Python 会告诉我们 9 squares 中。但是如果我们再次同样的问题,Python 会告诉我们 9 不在 squares 中。

>>> 9 in squares
True
>>> 9 in squares
False

我们问了两次相同的问题,而 Python 给了我们两个不同的答案。

陷阱 3:解包

这个字典有两个键值对

>>> counts = {'apples': 2, 'oranges': 1}

让我们使用多重赋值来解包这个字典

>>> x, y = counts

您可能期望在解包这个字典时,我们会得到键值对,或者可能会得到一个错误。

但是解包字典不会引发错误,也不会返回键值对。当您解包字典时,您会得到键

>>> x
'apples'

在了解一些为这些 Python 代码片段提供动力的逻辑之后,我们将回到这些陷阱。

回顾:Python 的 for 循环

Python 没有传统的 for 循环。为了解释我的意思,让我们看一下另一种编程语言中的 for 循环。

这是一个用 JavaScript 编写的传统 C 风格的 for 循环

let numbers = [1, 2, 3, 5, 7];
for (let i = 0; i < numbers.length; i += 1) {
    print(numbers[i])
}

JavaScript、C、C++、Java、PHP 和一大堆其他编程语言都具有这种 for 循环。但是 Python 没有

Python 没有传统的 C 风格的 for 循环。我们确实在 Python 中有我们称之为 for 循环的东西,但它的工作方式类似于 foreach 循环

这是 Python 风格的 for 循环

numbers = [1, 2, 3, 5, 7]
for n in numbers:
    print(n)

与传统的 C 风格的 for 循环不同,Python 的 for 循环没有索引变量。没有索引初始化、边界检查或索引递增。Python 的 for 循环为我们完成了循环遍历 numbers 列表的所有工作

因此,虽然我们在 Python 中确实有 for 循环,但我们没有传统的 C 风格的 for 循环。我们称之为 for 循环的东西的工作方式非常不同。

定义:可迭代对象和序列

现在我们已经解决了 Python 中的无索引 for 循环,让我们先明确一些定义。

可迭代对象是任何您可以使用 Python 中的 for 循环遍历的对象。可迭代对象可以被循环遍历,任何可以被循环遍历的对象都是可迭代对象。

for item in some_iterable:
    print(item)

序列是一种非常常见的可迭代对象。列表、元组和字符串都是序列。

>>> numbers = [1, 2, 3, 5, 7]
>>> coordinates = (4, 5, 7)
>>> words = "hello there"

序列是具有一组特定功能的可迭代对象。它们可以从 0 开始索引,到序列长度减 1 结束,它们有长度,并且可以被切片。列表、元组、字符串和所有其他序列都以这种方式工作。

>>> numbers[0]
1
>>> coordinates[2]
7
>>> words[4]
'o'

Python 中有很多东西是可迭代对象,但并非所有可迭代对象都是序列。集合、字典、文件和生成器都是可迭代对象,但这些东西都不是序列。

>>> my_set = {1, 2, 3}
>>> my_dict = {'k1': 'v1', 'k2': 'v2'}
>>> my_file = open('some_file.txt')
>>> squares = (n**2 for n in my_set)

因此,任何可以使用 for 循环遍历的对象都是可迭代对象,而序列是可迭代对象的一种类型,但 Python 还有许多其他类型的可迭代对象。

Python 的 for 循环不使用索引

您可能会认为在底层 Python 的 for 循环使用索引进行循环。在这里,我们正在使用 while 循环和索引手动循环遍历一个可迭代对象

numbers = [1, 2, 3, 5, 7]
i = 0
while i < len(numbers):
    print(numbers[i])
    i += 1

这适用于列表,但并非所有情况都适用。这种循环方式仅适用于序列

如果我们尝试使用索引手动循环遍历一个集合,我们会得到一个错误

>>> fruits = {'lemon', 'apple', 'orange', 'watermelon'}
>>> i = 0
>>> while i < len(fruits):
...     print(fruits[i])
...     i += 1
...
Traceback (most recent call last):
File "<stdin>", line 2, in <module>
TypeError: 'set' object does not support indexing

集合不是序列,因此它们不支持索引。

我们不能通过使用索引手动循环遍历 Python 中的每个可迭代对象。这对于不是序列的可迭代对象根本不起作用。

迭代器驱动 for 循环

因此,我们已经看到 Python 的 for 循环在底层一定没有使用索引。相反,Python 的 for 循环使用迭代器

迭代器是驱动可迭代对象的东西。您可以从任何可迭代对象中获取迭代器。并且您可以使用迭代器手动循环遍历它来自的可迭代对象。

让我们看看它是如何工作的。

这里有三个可迭代对象:一个集合、一个元组和一个字符串。

>>> numbers = {1, 2, 3, 5, 7}
>>> coordinates = (4, 5, 7)
>>> words = "hello there"

我们可以使用 Python 的内置 iter 函数来向这些可迭代对象中的每一个请求一个迭代器。无论我们处理的是哪种类型的可迭代对象,将可迭代对象传递给 iter 函数总是会返回给我们一个迭代器。

>>> iter(numbers)
<set_iterator object at 0x7f2b9271c860>
>>> iter(coordinates)
<tuple_iterator object at 0x7f2b9271ce80>
>>> iter(words)
<str_iterator object at 0x7f2b9271c860>

一旦我们有了迭代器,我们可以用它做的一件事就是通过将其传递给内置的 next 函数来获取它的下一个项目。

>>> numbers = [1, 2, 3]
>>> my_iterator = iter(numbers)
>>> next(my_iterator)
1
>>> next(my_iterator)
2

迭代器是有状态的,这意味着一旦您从它们中消耗了一个项目,它就消失了。

如果您从迭代器请求 next 项,但没有更多项目,您将收到一个 StopIteration 异常

>>> next(my_iterator)
3
>>> next(my_iterator)
Traceback (most recent call last):
  File "", line 1, in 
StopIteration

因此,您可以从每个可迭代对象中获取迭代器。您可以使用迭代器做的唯一事情是使用 next 函数向它们请求下一个项目。如果您将它们传递给 next,但它们没有下一个项目,则会引发 StopIteration 异常。

您可以将迭代器视为无法重新装填的 Pez 糖果分配器。您可以取出 Pez 糖果,但一旦取出 Pez 糖果就无法放回,并且一旦分配器空了,它就没用了。

不使用 for 循环进行循环

现在我们已经了解了迭代器以及 iternext 函数,我们将尝试在不使用 for 循环的情况下手动循环遍历一个可迭代对象。

我们将通过尝试将这个 for 循环转换为 while 循环来做到这一点

def funky_for_loop(iterable, action_to_do):
    for item in iterable:
        action_to_do(item)

为此,我们将

  1. 从给定的可迭代对象中获取一个迭代器
  2. 重复从迭代器中获取下一个项目
  3. 如果我们成功获取了下一个项目,则执行 for 循环的主体
  4. 如果在获取下一个项目时收到 StopIteration 异常,则停止我们的循环
def funky_for_loop(iterable, action_to_do):
    iterator = iter(iterable)
    done_looping = False
    while not done_looping:
        try:
            item = next(iterator)
        except StopIteration:
            done_looping = True
        else:
            action_to_do(item)

我们刚刚通过使用 while 循环和迭代器重新发明了一个 for 循环。

上面的代码几乎定义了 Python 中循环在底层的工作方式。如果您了解内置的 iternext 函数如何用于循环遍历事物,您就了解 Python 的 for 循环是如何工作的。

事实上,您将了解的不仅仅是 Python 中 for 循环的工作方式。所有形式的循环遍历可迭代对象都以这种方式工作。

迭代器协议是一种花哨的说法,意思是“Python 中循环遍历可迭代对象是如何工作的”。它本质上是 Python 中 iternext 函数工作方式的定义。Python 中所有形式的迭代都由迭代器协议驱动。

迭代器协议被 for 循环使用(正如我们已经看到的)

for n in numbers:
    print(n)

多重赋值也使用迭代器协议

x, y, z = coordinates

星号表达式使用迭代器协议

a, b, *rest = numbers
print(*numbers)

许多内置函数也依赖于迭代器协议

unique_numbers = set(numbers)

Python 中任何与可迭代对象一起工作的东西都可能以某种方式使用迭代器协议。每当您在 Python 中循环遍历可迭代对象时,您都依赖于迭代器协议。

生成器是迭代器

因此您可能会想:迭代器看起来很酷,但它们也只像是实现细节,而我们作为 Python 用户,可能不需要关心它们。

我有消息要告诉您:在 Python 中直接使用迭代器非常常见。

这里的 squares 对象是一个生成器

>>> numbers = [1, 2, 3]
>>> squares = (n**2 for n in numbers)

生成器是迭代器,这意味着您可以对生成器调用 next 以获取其下一个项目

>>> next(squares)
1
>>> next(squares)
4

但是如果您以前使用过生成器,您可能知道您也可以循环遍历生成器

>>> squares = (n**2 for n in numbers)
>>> for n in squares:
...     print(n)
...
1
4
9

如果您可以在 Python 中循环遍历某个对象,则它是可迭代对象

因此生成器是迭代器,但生成器也是可迭代对象。这里发生了什么?

我骗了您

因此,当我之前解释迭代器的工作原理时,我跳过了一个关于它们的重要细节。

迭代器是可迭代对象。

我再说一遍:Python 中的每个迭代器也是一个可迭代对象,这意味着您可以循环遍历迭代器。

因为迭代器也是可迭代对象,所以您可以使用内置的 iter 函数从迭代器中获取迭代器

>>> numbers = [1, 2, 3]
>>> iterator1 = iter(numbers)
>>> iterator2 = iter(iterator1)

请记住,当我们对可迭代对象调用 iter 时,可迭代对象会给我们迭代器。

当我们对迭代器调用 iter 时,它总是会返回自身

>>> iterator1 is iterator2
True

迭代器是可迭代对象,并且所有迭代器都是它们自己的迭代器。

def is_iterator(iterable):
    return iter(iterable) is iterable

困惑了吗?

让我们回顾一下这些术语。

  • 可迭代对象是您可以迭代的对象
  • 迭代器是实际执行迭代可迭代对象的代理

此外,在 Python 中,迭代器也是可迭代对象,并且它们充当它们自己的迭代器。

因此,迭代器是可迭代对象,但它们不具有某些可迭代对象所具有的各种功能。

迭代器没有长度,并且无法索引

>>> numbers = [1, 2, 3, 5, 7]
>>> iterator = iter(numbers)
>>> len(iterator)
TypeError: object of type 'list_iterator' has no len()
>>> iterator[0]
TypeError: 'list_iterator' object is not subscriptable

从我们作为 Python 程序员的角度来看,您可以对迭代器做的唯一有用的事情是将它传递给内置的 next 函数或循环遍历它

>>> next(iterator)
1
>>> list(iterator)
[2, 3, 5, 7]

如果我们第二次循环遍历迭代器,我们将一无所获

>>> list(iterator)
[]

您可以将迭代器视为惰性可迭代对象,它们是一次性使用的,这意味着它们只能被循环遍历一次。

正如您在下面的真值表中看到的,可迭代对象不总是迭代器,但迭代器总是可迭代对象

对象 可迭代? 迭代器?
可迭代对象 ✔️
迭代器 ✔️ ✔️
生成器 ✔️ ✔️
列表 ✔️

完整的迭代器协议

让我们从 Python 的角度定义迭代器是如何工作的。

可迭代对象可以传递给 iter 函数以获取它们的迭代器。

迭代器

  • 可以传递给 next 函数,这将给出它们的下一个项目,如果没有更多项目,则会引发 StopIteration 异常
  • 可以传递给 iter 函数,并将返回自身

这些陈述的反面也成立

  • 任何可以传递给 iter 而不会出现 TypeError 的对象都是可迭代对象
  • 任何可以传递给 next 而不会出现 TypeError 的对象都是迭代器
  • 任何在传递给 iter 时返回自身的对象都是迭代器

这就是 Python 中的迭代器协议。

迭代器实现惰性

迭代器允许我们使用和创建惰性可迭代对象,这些对象在我们向它们请求下一个项目之前不做任何工作。因为我们可以创建惰性可迭代对象,所以我们可以创建无限长的可迭代对象。并且我们可以创建节省系统资源、节省内存和节省 CPU 时间的可迭代对象。

迭代器无处不在

您已经在 Python 中看到了很多迭代器。我已经提到生成器是迭代器。许多 Python 的内置类也是迭代器。例如,Python 的 enumeratereversed 对象是迭代器。

>>> letters = ['a', 'b', 'c']
>>> e = enumerate(letters)
>>> e
<enumerate object at 0x7f112b0e6510>
>>> next(e)
(0, 'a')

在 Python 3 中,zipmapfilter 对象也是迭代器。

>>> numbers = [1, 2, 3, 5, 7]
>>> letters = ['a', 'b', 'c']
>>> z = zip(numbers, letters)
>>> z
<zip object at 0x7f112cc6ce48>
>>> next(z)
(1, 'a')

Python 中的文件对象也是迭代器。

>>> next(open('hello.txt'))
'hello world\n'

Python、标准库和第三方 Python 库中内置了许多迭代器。这些迭代器都像惰性可迭代对象一样工作,通过延迟工作直到您向它们请求下一个项目的那一刻。

创建您自己的迭代器

了解您已经在使用迭代器很有用,但我也想让您知道您可以创建自己的迭代器和自己的惰性可迭代对象。

这个类创建了一个迭代器,它接受一个数字的可迭代对象,并在循环遍历时提供每个数字的平方。

class square_all:
    def __init__(self, numbers):
        self.numbers = iter(numbers)
    def __next__(self):
        return next(self.numbers) ** 2
    def __iter__(self):
        return self

但是在我们开始循环遍历这个类的实例之前,不会完成任何工作。

这里我们有一个无限长的可迭代对象 count,您可以看到 square_all 接受 count,而无需完全循环遍历这个无限长的可迭代对象

>>> from itertools import count
>>> numbers = count(5)
>>> squares = square_all(numbers)
>>> next(squares)
25
>>> next(squares)
36

这个迭代器类有效,但我们通常不以这种方式创建迭代器。通常,当我们想要创建一个自定义迭代器时,我们会创建一个生成器函数

def square_all(numbers):
    for n in numbers:
        yield n**2

这个生成器函数等同于我们上面创建的类,并且它的工作方式基本相同。

那个 yield 语句可能看起来很神奇,但它非常强大:yield 允许我们在来自 next 函数的调用之间暂停我们的生成器函数。yield 语句是将生成器函数与常规函数分开的东西。

我们可以实现这个相同迭代器的另一种方法是使用生成器表达式。

def square_all(numbers):
    return (n**2 for n in numbers)

这与我们的生成器函数执行相同的操作,但它使用的语法看起来像列表推导式。如果您需要在代码中创建一个惰性可迭代对象,请考虑迭代器,并考虑创建一个生成器函数或生成器表达式。

迭代器如何改进您的代码

一旦您接受了在代码中使用惰性可迭代对象的想法,您会发现有很多可能性可以发现或创建辅助函数,以帮助您循环遍历可迭代对象和处理数据。

惰性和求和

这是一个 for 循环,它对 Django queryset 中的所有可计费工时求和

hours_worked = 0
for event in events:
    if event.is_billable():
        hours_worked += event.duration

这是使用生成器表达式进行惰性求值的代码,它执行相同的操作

billable_times = (
    event.duration
    for event in events
    if event.is_billable()
)

hours_worked = sum(billable_times)

请注意,我们代码的结构发生了巨大变化。

将我们的可计费时间转换为惰性可迭代对象使我们能够命名以前未命名的东西(billable_times)。这也使我们能够使用 sum 函数。我们以前无法使用 sum,因为我们甚至没有可传递给它的可迭代对象。迭代器允许您从根本上改变您组织代码的方式。

惰性和跳出循环

此代码打印出日志文件的前 10 行

for i, line in enumerate(log_file):
    if i >= 10:
        break
    print(line)

此代码执行相同的操作,但我们使用 itertools.islice 函数在循环时惰性地获取文件的前 10 行

from itertools import islice

first_ten_lines = islice(log_file, 10)
for line in first_ten_lines:
    print(line)

我们创建的 first_ten_lines 变量是一个迭代器。同样,使用迭代器使我们能够命名以前未命名的东西(first_ten_lines)。命名事物可以使我们的代码更具描述性和更易读性。

作为奖励,我们还消除了循环中对 break 语句的需求,因为 islice 实用程序为我们处理了中断。

您可以在标准库中的 itertools 以及第三方库(如 boltonsmore-itertools)中找到更多迭代辅助函数。

创建您自己的迭代辅助函数

您可以在标准库和第三方库中找到用于循环的辅助函数,但您也可以自己创建!

此代码创建一个列表,其中包含序列中连续值之间的差异。

current = readings[0]
for next_item in readings[1:]:
    differences.append(next_item - current)
    current = next_item

请注意,此代码有一个额外的变量,我们需要在每次循环时都赋值。另请注意,此代码仅适用于我们可以切片的对象,例如序列。如果 readings 是生成器、zip 对象或任何其他类型的迭代器,则此代码将失败。

让我们编写一个辅助函数来修复我们的代码。

这是一个生成器函数,它为我们提供了给定可迭代对象中每个项目的当前项目和紧随其后的项目

def with_next(iterable):
    """Yield (current, next_item) tuples for each item in iterable."""
    iterator = iter(iterable)
    current = next(iterator)
    for next_item in iterator:
        yield current, next_item
        current = next_item

我们正在手动从我们的可迭代对象中获取一个迭代器,对其调用 next 以获取第一个项目,然后循环遍历我们的迭代器以获取所有后续项目,并在此过程中跟踪我们的最后一个项目。此函数不仅适用于序列,而且适用于任何类型的可迭代对象。

这与之前的代码相同,但我们正在使用我们的辅助函数,而不是手动跟踪 next_item

differences = []
for current, next_item in with_next(readings):
    differences.append(next_item - current)

请注意,此代码没有围绕我们的循环对 next_item 进行笨拙的赋值。with_next 生成器函数为我们处理了跟踪 next_item 的工作。

另请注意,此代码已足够简洁,如果我们愿意,我们甚至可以复制粘贴到列表推导式中

differences = [
    (next_item - current)
    for current, next_item in with_next(readings)
]

重新审视循环陷阱

现在我们准备好回到我们之前看到的那些奇怪的示例,并尝试弄清楚发生了什么。

陷阱 1:耗尽迭代器

这里我们有一个生成器对象,squares

>>> numbers = [1, 2, 3, 5, 7]
>>> squares = (n**2 for n in numbers)

如果我们将此生成器传递给 tuple 构造函数,我们将获得一个包含其项目的元组

>>> numbers = [1, 2, 3, 5, 7]
>>> squares = (n**2 for n in numbers)
>>> tuple(squares)
(1, 4, 9, 25, 49)

如果我们然后尝试计算此生成器中数字的 sum,我们将得到 0

>>> sum(squares)
0

这个生成器现在是空的:我们已经耗尽了它。如果我们再次尝试从中创建一个元组,我们将得到一个空元组

>>> tuple(squares)
()

生成器是迭代器。迭代器是一次性使用的可迭代对象。它们就像无法重新装填的 Hello Kitty Pez 糖果分配器。

陷阱 2:部分消耗迭代器

再次,我们有一个生成器对象,squares

>>> numbers = [1, 2, 3, 5, 7]
>>> squares = (n**2 for n in numbers)

如果我们询问 9 是否在这个 squares 生成器中,我们将得到 True

>>> 9 in squares
True

但是如果我们再次问同样的问题,我们将得到 False

>>> 9 in squares
False

当我们询问 9 是否在这个生成器中时,Python 必须循环遍历这个生成器才能找到 9。如果我们在检查 9 后继续循环遍历它,我们将只得到最后两个数字,因为我们已经消耗了此点之前的数字

>>> numbers = [1, 2, 3, 5, 7]
>>> squares = (n**2 for n in numbers)
>>> 9 in squares
True
>>> list(squares)
[25, 49]

询问某物是否包含在迭代器中会部分消耗迭代器。在开始循环遍历迭代器之前,无法知道迭代器中是否包含某物。

陷阱 3:解包是迭代

当您循环遍历字典时,您会得到键

>>> counts = {'apples': 2, 'oranges': 1}
>>> for key in counts:
...     print(key)
...
apples
oranges

当您解包字典时,您也会得到键

>>> x, y = counts
>>> x, y
('apples', 'oranges')

循环依赖于迭代器协议。可迭代对象解包也依赖于迭代器协议。解包字典实际上与循环遍历字典相同。两者都使用迭代器协议,因此在两种情况下您都会得到相同的结果。

回顾和相关资源

序列是可迭代对象,但并非所有可迭代对象都是序列。当有人说“可迭代对象”这个词时,您只能假设它们的意思是“您可以迭代的东西”。不要假设可迭代对象可以被循环遍历两次、被询问它们的长度或被索引。

迭代器是 Python 中最基本的迭代形式。如果您想在代码中创建一个惰性可迭代对象,请考虑迭代器,并考虑创建一个生成器函数或生成器表达式。

最后,请记住,Python 中的每种迭代类型都依赖于迭代器协议,因此理解迭代器协议是理解 Python 中循环的总体情况的关键。

以下是我推荐的相关文章和视频

本文基于作者去年在 DjangoCon AUPyGothamNorth Bay Python 上做的“更佳的循环”演讲。要获得更多类似内容,请参加 PYCON,该会议将于 2018 年 5 月 9 日至 17 日在俄亥俄州哥伦布市举行。

User profile image.
Trey Hunner 通过现场团队培训和 https://www.PythonMorsels.com 帮助 Python 和 Django 团队将经验丰富的开发人员转变为经验丰富的 Python 开发人员。

4 条评论

我理解指出这些问题的一些意义,但似乎在撰写一篇冗长且重复的文章的过程中,您只是增加了困惑。C++ 不是 Perl,也不是 Python。您必须了解每种语言如何处理各种逻辑情况,而不是尝试将 Perl 翻译成 Python,反之亦然。您还必须创建可以放在一边并在一年或两年后取出时快速理解的代码。

有趣的话题。我对“pez 糖果分配器”是什么感到困惑,但这篇文章内容丰富。谢谢

感谢这篇文章 - 我学到了很多,因为我以前从未真正理解 Python 中的迭代器。
使用 Pez 糖果分配器作为隐喻显示了您的年龄 ;-)

非常感谢您撰写这篇文章!您清楚地解释了很多我以前只有模糊想法的事情。这非常有用。我是 Python 新手,有时很难找到不假设读者是经验丰富的开发人员的解释。再次感谢!

Creative Commons License本作品根据 Creative Commons Attribution-Share Alike 4.0 International License 获得许可。
© . All rights reserved.