理解 Python 中的 for 循环

Looping Gotchas

我们将通过一些「gotchas」(陷阱)来开始今天的旅程。等我们知道 Python 中的 for 循环的原理时,我们再回过头来看这些 gotchas,并解释原因。

Gotcha 1: Looping Twice

假设我们有一个数字 list(列表)以及一个生成这些数字的平方的 generator(生成器):

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

我们可以将我们的 generator 对象传给 tuple 构造函数来变成一个 tuple(元组):

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

之后如果我们将同样的 generator 对象传给 sum 函数,我们可能希望返回这些数字的和,也就是 88。

>>> sum(squares)
0

但是我们得到的是 0

Gotcha 2: Containment Checking

我们还用上面相同的数字 list 和 generator 对象:

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

如果我们问 9 是否在 squares 中,那么 Python 会告诉我们在。但是当我们再问一次时,回答是不在。

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

我们把一个问题问了两遍,但是 Python 给了两个不同的回答。

Gotcha 3: Unpacking

这个 dictionary 有两个键值对:

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

让我们用 multiple assignment 来解包(unpack)这个 dictionary:

>>> x, y = counts

你可能期望的是当我们解包这个 dictionary 时,我们会得到键值对,或者发生错误。

但是解包 dictionary 并不会有任何错误发生,也没有得到键值对,反而你得到的是键:

>>> x
'apples'

当我们学到这写代码片段背后的逻辑时,我们再回过头来看这些代码。

Review: Python’s for loop

Python 中的 for 循环不是传统的 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 以及其他一大堆都是这种 C 风格的 for 循环,但是 Python 不是

Python 没有 C 风格的 for 循环,但是的确有 for 循环,但是原理类似于 foreach 循环

这是 Python 的 for 循环风格:

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

不像传统的 C 风格的 for 循环,Python 的 for 循环没有索引变量。没有索引初始化、边界检查和索引增加。Python 的 for 循环都把这些工作为我们做了。

所以在 Python 中确实有 for 循环,但不是传统的 C 风格的 for 循环。我们称之为 for 循环的东西的工作方式很不一样。

Definitions: Iterables and Sequences

现在我们已经知道 Python 的 for 循环没有索引,接下来先让我们做一些定义。

Python 中任何你可以通过 for 循环来循环的东西都是一个 iterable(可迭代对象)。iterable 可以被循环,任何可被循环的东西都是一个 iterable。

for item in some_iterable:
    print(item)

Sequences 是一种非常常见的 iterable 类型。 Lists、tuples、和 strings 都是 sequences。

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

Sequences 是具有一组特定特征的 iterable。他们可以从 0 开始索引,并结束于长度 -1,他们有长度(length),可以被切片(sliced)。Lists、tuples、strings 和 其他所有的 sequences 都是这样。

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

Python 中许多东西都是 iterable,但并不是所有的 iterable 都是 sequences。Sets(集合)、dictionaries、files 和 generators 都是 iterable,但他们都不是 sequences。

>>> 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 循环来循环的东西都是一个 iterable,sequences 是一种 iterable 类型,但是 Python 也有许多其他类型的 iterable。

Python’s for loops don’t use indexes

你可能会认为 Python 的 for 循环本质上还是用的索引。下面我们使用 while 循环和索引来遍历一个 iterable:

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

这种方式适合 lists,但是不是任何对象都可以这么用。这种方式只适合 sequences。

如果我们试图对一个 set 进行上述遍历,我们会得到一个错误:

>>> 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

Sets 不是 sequences,所以他们不支持索引。

Python 中我们不能使用索引来遍历每一个 iterable。这对于非 sequences 的 iterable 不起作用。

Iterators power for loops

目前为止我们已经看到 Python 不可能使用索引来进行循环。相反,Python 的 for 循环使用 iterator(迭代器)。

Iterators 就是增强 iterable 的东西。你可以从任何一个 iterable 得到一个 iterator。你可以使用 iterator 手动遍历一个 iterable。

让我们看看怎么实现。

这里有三个 iterable:一个 set,一个 tuple,一个 string:

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

我们可以使用 Python 的内置函数 iter 来把这些 iterable 变成 iterator。将一个 iterable 作为参数传给 iter 总是会返回一个 iterator,无论 iterable 是什么类型。

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

一旦我们有了一个 iterator,我们就可以使用内置的 next 函数来获取下一项。

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

Iterators 是有状态的,意味着一旦你消耗了一项那这项就消失了。

如果你用 next 取下一项然而这时候已经没有下一项的时候,你就会得到一个 StopIteration 错误:

>>> next(iterator)
3
>>> next(iterator)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

所以你可以从每一个 iterable 得到一个 iterator。对于 iterator 你可以做得唯一的一件事就是使用 next 函数取其下一项。但如果已经没有下一项了,那么你就会得到一个 StopIteration 错误。

Looping without a for loop

现在我们已经学习了 iterator 以及 nextiter 函数。我们将要尝试不通过 for 循环来遍历一个 iterable。

我们尝试将 for 循环转成 while 循环:

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

要做到这一点,我们将:

  1. 从给定的 iterable 获得一个 iterator
  2. 从得到的 iterator 中重复获取下一项
  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 循环和 iterator 重新发明了一个 for 循环。

上面的代码很好的展现了 Python 中的循环是如何工作的。如果你理解了内置函数 iternext 是如何作用于循环的,那么你就理解了 Python for 循环的工作方式。

事实上相比理解 for 循环的工作方式,你会了解的更多。所有循环都是这么工作的。

Iterator protocol迭代器协议)描述了 Python 中循环的工作方式。它本质上是 iternext 函数工作方式的定义。Python 中的所有迭代形式都由 iterator protocol 提供支持。

for 循环使用的就是 iterator protocol(就像我们所看到的):

for n in numbers:
    print(n)

Multiple assignment 也使用 iterator protocol:

x, y, z = coordinates

Star expressions(星号表达式)也使用 iterator protocol:

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

还有许多内置函数都依赖 iterator protocol:

unique_numbers = set(numbers)

Python 中与 iterable 一起使用的任何东西都可能以某种方式使用 iterator protocol。每当你在 Python 中循环迭代时,你就依赖于 iterator protocol。

Generators are iterators

你可能会想:iterators 很厉害,但是他们好像只是一种实现细节,作为 Python 用户我们似乎并不关心这个。

我可以告诉你的是:在 Python 中直接使用 iterator 是很常见的。

这里的 squares 对象是一个 generator:

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

Generators 就是一种 iterators,意味着你可以使用 next 函数来获取下一项:

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

如果你之前使用过 generators,那么你就知道你可以使用循环来遍历他们:

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

在 Python 中如果一个对象可以被循环,那么这就是一个 iterable。(目前为止作者好像已经提了很多次 ?

所以 generators 是 iterator,但 generators 也是 iterable。为什么?

I lied to you

当我之前解释 iterator 是如何工作的时候,我跳过了一个重要的细节。

Iterators 是 iterable.

我再说一遍:Python 中每一个 iterator 也是一个 iterable,意味着你可以遍历他们。

由于 iterator 也是 iterable,你也可以使用 iter 函数从一个 iterator 得到一个 iterator:

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

记住当我们将一个 iterable 穿给 iter 函数的时候我们就可以得到一个 iterator。(真的重复很多遍了。。。

当我们将 iterator 传给 iter 函数时总是会返回他自己:

>>> iterator1 is iterator2
True

Iterators 都是 iterable,所有的 iterator 都是他们自己的 iterators。

有点迷是吗?

让我们回顾下这些术语。

你可以遍历一个 iterable,而 iterator 就是实际执行遍历操作的代理。

另外,Python 中 iterator 也是 iterable,而且他们也是自己的 iterators。(我真的不想再说这个了。。。

所以 iterator 是 iterable,但是他们没有一些 iterable 所拥有的特性。

Iterators 没有长度,他们不能使用索引:

>>> 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 程序员的我们来说,iterators 唯一有用的是可以使用 next 函数获取其下一项:

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

如果我们对一个 iterator 遍历第二次,那么我们什么也得不到:

>>> list(iterator)
[]

你可以把 iterator 看成是 lazy iterable,只能用一次,也就是只能遍历一次。

Object

Iterable?

Iterator?

Iterable

✔️

Iterator

✔️

✔️

Generator

✔️

✔️

List

✔️

正如上面的真值表所展示的,iterable 不总是 iterators,但是 iterator 总是 iterable。

The iterator protocol, in full

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

iterable 可以作为参数传入 iter 函数来得到一个 iterator。

Iterators:

  1. 可以作为参数传入 next 函数来获取下一项,当没有元素时抛出 StopIteration 异常
  2. 可以作为参数传入 iter 函数,返回他们本身

这些命题的逆命题也同样成立:

  1. 任何可以传入 iter 并没有抛出 TypeError 异常的对象都是 iterable
  2. 任何可以传入 next 并没有抛出 TypeError 异常的对象都是 iterator
  3. 任何可以传入 iter 并返回他们本身的对象都是 iterator

这就是 Python 中的 iterator protocol。

Iterators enable laziness

Iterators 允许我们创建并使用 lazy iterable,在我们要求获取下一项之前他们不会做任何事。由于我们可以创建 lazy iterable,我们可以创建无限长的 iterable。我们也可以创建保有系统资源(are conservative with system resources)的 iterable,可以为我们节省内存和 CPU 时间。

Iterators are everywhere

在 Python 中你已经见过很多 iterator 了。我之前已经提到 generator 就是 iterator 了。许多 Python 的内置类也是 iterator。例如 enumeratereversed 都是 iterator。

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

在 Python 3 中,zipmapfilter 也是 iterator。

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

而且文件对象也是 iterator。

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

Python 中有许多内置的 iterator,第三方包中也有许多。这些 iterator 就像 lazy iterable,他们不会做任何事,直到你要求获取下一项。

Creating your own iterator

你可能已经在使用 iterator 了,但是我想让你知道的是你可以创建你自己的 iterator 和 lazy iterable。

下面这个类创建了一个 iterator,接受一个数字的 iterable 作为输入,并且当循环的时候输出每一个数字的平方。

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

但是任何事情都不会发生直到我们开始循环该类的实例。

这里我们有一个无限长的 iterable countsquare_all 可以接受 count 作为参数而无需完全遍历 count

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

这个 iterator 类是没问题的,但是通常我们不会这么写。通常当我们想要创建一个自定义 iterator 的时候,我们会创建一个 generator 函数:

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

这个 iterator 函数和上面的类是等价的,工作方式也是一样的。

这个 yield 语句看起来比较神奇,但是确实很强大:yield 函数允许我们在下一次 next 函数调用前暂停。Generator 函数和普通函数的区别就在于 yield 语句。

实现上述相同的 iterator 的另一种方式是使用 generator expression(生成器表达式)。

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

这个和 generator 函数是一样的,只是语法类似于 list comprehension(列表推导式)。如果你的代码中需要 lazy iterable,可以考虑下用 generator 函数或者 generator expression 来创建一个 iterator。

How iterators can improve your code

一旦你接受了在代码中使用 lazy iterable 的思想,那么你就会发现很多时候都可以创建一个帮助函数来协助循环和处理数据。

Laziness and summing

这是个 Django 的查询集(queryset)用于对所有计费小时数(billable hours)求和的 for 循环:

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

下面是 generator expression 版本:

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

hours_worked = sum(billable_times)

注意到代码已经发生了很大变化。

上面这种使用 lazy iterable 的方式允许我们命名之前没有命名的东西(billable_times),也可以使用 sum 函数。之前我们不能使用 sum 函数是因为我们没有一个 iterable。Iterator 可以让我们从根本上改变代码的组织方式。

Laziness and breaking out of loops

下面的代码输出一个日志文件的前 10 行:

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

下面的代码实现了同样的功能,但是我们使用了 itertools.islice 函数来实现「懒加载」:

from itertools import islice

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

我们这里创建的 first_ten_lines 变量是个 iterator。再次说下有了 iterator 我们就可以命名之前不能命名的东西(first_ten_lines)。这样有助于使我们的代码可读性更强。

此外我们也不用再写 break 语句了,因为 islice 函数已经帮我们做了。

你可以在内置库 itertools 和 第三方库如 boltonsmore-itertools 中找到许多 iterator 相关函数。

Creating your own iteration helpers

你可以再内置库或者第三方库中找到一些 iterator 的帮助函数,但是你也可以自己写!

下面的代码用于计算一个序列中相邻两个数字的差,返回一个 list。

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

注意这个代码需要在每一次循环中额外的给一个变量赋值。同时这个代码也只适用于有索引功能的对象,比如 sequence。如果 readings 是一个 generator,一个 zip 对象,或者其他类型的 iterator,那么这个代码就不能用了。

让我们来写一个帮助函数来修复这个问题。

这是一个 generator 函数,对于一个 iterable,在每次循环中输出当前项和下一项:

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

这里我们手动从 iterable 得到了一个 iterator,使用 next 函数获取下一项(译者注:即第一项),然后在遍历 iterator 来获取后面所有项,同时追踪最后一项。这个函数不仅适用于 sequence,也适用于所有的 iterable 对象。

下面的代码使用上面的 with_next 函数实现了相同的功能,不用再手动追踪 next_item 了(译者注:但是在函数里面。。。):

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

注意我们不必在循环中略显笨拙地对 next_item 进行赋值,with_next 函数帮我们做了这些工作。

同时这个代码也足够紧凑,如果需要的话我们甚至可以转换成一个 list comprehension。

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

Looping Gotchas: Revisited

此时我们可以回到刚开始我们提到的那些奇怪的问题,让我们来搞清楚发生了什么。

Gotcha 1: Exhausting an Iterator

这里我们有一个 generator 对象,squares

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

如果把他传入 tuple 构造函数,那么我们会得到一个 tuple:

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

然后如果我们计算这个 generator 中所有数字的和,那么结果就会是 0

>>> sum(squares)
0

因为此时这个 generator 已经是空的了,我们已经消耗完了(译者注:遍历完了)。如果我们再次使用 tuple 构造函数,那么就会得到一个空的 tuple:

>>> tuple(squares)
()

Genetor 是一种 iterator,iterator 是只能使用一次的 iterable。

Gotcha 2: Partially-Consuming an Iterator

同样我们有一个 generator 对象,squares

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

如果我们要查询 9 是否在其中,那么我们会得到 True

>>> 9 in squares
True

但如果我们再查一次,就会得到 False

>>> 9 in squares
False

当我们第一次查询 9 是否存在时,Python 就会去遍历这个 generator。如果在此之后我们继续遍历这个 generator,那么只能得到最后两个数字,因为前面的数字都已经遍历过了,已经没了:

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

查询某对象是否在一个 iterator 中的时候会部分消耗(译者注:遍历)这个 iterator。如果不循环我们是没办法知道一个对象是否在 iterator 中的。

Gotcha 3: Unpacking is iteration

当你遍历 dict 的时候得到的是 key:

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

当你解包(unpack)一个 dict 的时候,你得到的也是 key:

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

循环依赖于 iterator protocol。解包 iterable 同样依赖于 iterator protocol。解包 dict 和遍历 dict 是一样的,都是使用的 iterator protocol,所以得到的结果是一样的。

Recap and related resources

Sequence 是 iterable,但不是所有的 iterable 都是 sequence。当某个人说 「iterable」的时候,你可以假定他说的是「你可以遍历的东西」,但是不要假定你可以遍历两次,查询长度或者索引。

Iterator 是 Python 中最基本的 iterable 形式。如果你在代码中想要一个 lazy iterable,那么考虑 iterator,创建一个 generator 函数或者 generator expression。

最后请记住,Python 中的每种类型的遍历都依赖于 iterator protocol,因此理解 iterator protocol 是了解 Python 中循环的关键。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券