35

深入理解Python中的迭代

 5 years ago
source link: http://www.10tiao.com/html/262/201807/2651379403/1.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

我们先研究一下Python的for循环,看看它们是如何工作的,原理是什么。

Pythonfor循环与其它语言的for循环用法不一样。在本文中,我们将深入研究Pythonfor循环,了解它们是在底层如何工作的,以及背后的原理。

循环陷阱

我们先从一些“陷阱”开始讲起,在了解循环的工作原理之后,我们再回头看看这些陷阱并解释发生了什么。

陷阱1:循环两次

假设我们有一个数字列表和一个生成器,生成器会返回这些数字的平方:

我们可以将生成器传递给tuple构造器,使其变为一个元组:

如果继续使用该生成器并将其传递给sum函数,我们可能会期望得到这些数字的总和,即88。

但是我们得到了0。

陷阱2:检查是否包含

继续使用上面的数字列表和生成器:

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

我们进行了两次同样的查询,Python给了我们不同的答案。

陷阱3:解包

这个字典有两个键值对:

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

你可能期望在解包此字典时,我们将获得键值对,或者会有错误提示。
但是解包字典不会引发错误,也不会返回键值对。当你解包字典时,你只会获得键:

我们先学习一些背后的逻辑,再回头来看这些陷阱。

复习Python的for循环

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

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

JavaScript,C,C ++,Java,PHP和其它许多编程语言都有这种for循环。但Python确实没有。

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

这是Python的for循环风格:

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

因此,虽然Python中有for循环,但不是传统的C风格for循环。C风格的for循环工作方式与Python风格的大不一样。

定义:可迭代对象和序列
我们已经讲过Python中无索引的for循环,那么让我们来看看一些定义。

可迭代对象是任何可以用for循环遍历的东西。可迭代对象可以被循环遍历,任何可以被循环遍历的东西都是可迭代对象。

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

序列是有固定特征集的可迭代对象。它们可以从0开始索引,并以序列长度减1的值结束,它们有长度,可以分割。列表,元组,字符串和所有其它序列以这种方式工作。

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

所以任何可以用for循环遍历的东西都是可迭代对象,序列是一种可迭代对象,但Python也有许多其它类型的可迭代对象。

Python的for循环不使用索引

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

这适用于列表,但它不会对所有东西起作用。这种循环方式仅适用于序列

如果尝试用索引手动循环遍历一个集合,会抛出一个错误:

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

我们不能用索引手动循环Python中的所有可迭代对象,对于不是序列的可迭代对象不起作用。

迭代器驱动for循环

所以我们已经看到Python的for循环在底层不使用索引。Python的for循环使用迭代器。

迭代器驱动可迭代对象。你可以从任何可迭代对象中获得一个迭代器。你可以使用迭代器手动循环遍历可迭代对象。

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

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

我们可以使用Python的内置iter函数向每个可迭代对象要一个迭代器。将一个可迭代对象传递给iter函数就返回一个迭代器,无论我们操作什么类型的可迭代对象。

一旦我们有了迭代器,我们就可以用内置的next函数来获取它的下一个元素。

迭代器是有状态的,这意味着一旦你使用了一个元素,它就消失了。

如果你想从迭代器请求next元素,但已经没有更多元素了,就会发生StopIteration异常:

所以你可以从每个可迭代对象中获得一个迭代器。迭代器唯一能做的事情就是使用next函数获取它的下一个元素。如果使用next函数但是没有下一个元素,则会抛出StopIteration异常。

你可以将迭代器视为无法重新加载的Pez分配器。你可以把Pez移除,但是一旦Pez被移除它就不能放回去,一旦分配器空了,它就没用了。

不用for语句来循环

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

我们尝试将此for循环转换为while循环来实现:

为了做到这点,我们需要:

    1.从给定的可迭代对象中获取迭代器
    2.反复从迭代器获取下一个元素
    3.如果我们成功获得下一个元素,则执行for循环的主体
    4.如果我们在获取下一个元素时遇到StopIteration异常,那么就停止循环

我们通过while循环和迭代器实现了for循环的功能。

上面的代码基本定义了Python中循环的工作方式。如果你理解内置iter和next函数遍历循环的工作方式,你就会理解Python的for循环是如何工作的。

事实上,你不仅仅会理解 for 循环在 Python 中是如何工作的,可迭代对象所有形式的循环都以这种方式工作。

迭代器协议就像是在说“如何在Python中循环遍历可迭代对象”。它本质上是定义iter和next函数在Python中的工作方式。Python中所有形式的迭代都是由迭代器协议驱动的。

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

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

星形表达式使用迭代器协议:

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

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

生成器是迭代器

所以你可能会想:迭代器看起来很酷,但它们看起来像一个实现细节,我们作为 Python 的使用者,可能不需要关心它们。

我有消息告诉你:在 Python 中直接使用迭代器是很常见的。

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

生成器也是迭代器,这意味着你可以在生成器上调用next函数来获取它的下一个元素:

如果你之前用过生成器,你就知道也可以循环遍历生成器:

如果你可以在Python中循环遍历某个东西,那它就是可迭代对象。

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

我欺骗了你

之前我解释迭代器如何工作时,我跳过了某些重要细节。

迭代器是可迭代对象。

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

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

记住,当在可迭代对象上调用iter函数时,会返回迭代器。

当在迭代器上调用iter函数时,返回迭代器本身:

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

困惑了吗?

让我们回顾一下这些措辞:
    一个可迭代对象是你能够迭代的东西
    一个迭代器是对一个可迭代对象进行迭代的工具

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

所以迭代器是可迭代对象,但它们没有可迭代对象所具有的各种特性。

迭代器没有长度,也无法被索引:

从Python程序员的角度来看,迭代器唯一有用的地方是将其传递给内置的 next 函数,或者对其进行循环遍历:

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

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

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

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

完整的迭代器协议

让我们从Python的角度定义迭代器的工作方式。

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

迭代器:
    可以传递给next函数,返回下一个元素,如果没有更多元素,抛出StopIteration异常
    可以传递给iter函数并返回自身

这些语句反过来也是正确的::
    任何可以在不引发TypeError异常的情况下传递给iter函数的东西都是可迭代对象
    任何可以在不引发TypeError异常的情况下传递给next函数的东西都是一个迭代器
    任何可以传递给iter函数且返回自身的东西都是迭代器

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

迭代器是惰性的

迭代器可以创建惰性的可迭代对象,在我们要求获取下一个元素之前不做任何工作。因为可以创建惰性的可迭代对象,所以我们可以创建无限长的可迭代对象。我们可以创建节省系统资源的可迭代对象,这样既可以节省内存,又可以节省CPU运算时间。

迭代器无处不在

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

在Python 3中,zip,map和filter对象也是迭代器。

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

Python标准库和第三方库中内置了大量迭代器。这些迭代器都像惰性的可迭代对象一样,延迟工作直到你请求它们的下一个元素。

创建自己的迭代器

你应该知道你在使用迭代器,但我希望你也知道你可以创建自己的迭代器和惰性的可迭代对象。

这个类构造了一个迭代器,它接受一组可迭代的数字,并在循环时提供每个数字的平方。

但是在我们开始对该类的实例进行循环遍历之前,不会进行任何运算或操作。

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

这个迭代器类是有效的,但我们通常不会这样做。通常,当我们想要做一个定制的迭代器时,我们会定义一个生成器函数:

这个生成器函数等同于我们上面创建的类,它的工作原理是一样的。

这种yield语句似乎很神奇,但它非常强大:yield允许我们在调用next函数之间暂停生成器函数。 yield语句是将生成器函数与常规函数分离的东西。

另一种实现相同迭代器功能的方法是使用生成器表达式。

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

迭代器如何改进代码

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

惰性与求和

这是一个for循环,它对Django查询集中的所有可计费时间求和:

下面的代码是使用生成器表达式进行惰性计算,跟上面的代码结果一样:

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

将可计费时间变成一个惰性的可迭代对象,这样可以命名以前未命名的东西(billable_times)。也可以使用sum函数。之前不能使用sum函数是因为没有可迭代对象传递给它。迭代器允许你从根本上改变组织代码的方式。

惰性与跳出循环

这段代码打印出日志文件的前10行:

下面这段代码做了同样的事情,但在循环中使用了itertools.islice函数来惰性抓取文件的前10行:

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

另外,我们还在循环中删除了break语句,因为islice工具进行了中断。

你可以在标准库中的itertools以及第三方库的boltons和more-itertools中找到更多的迭代辅助函数。

创建自己的迭代辅助函数

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

这段代码列出了序列中连续值之间的差值列表。

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

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

下面是一个生成器函数,它为给定的可迭代对象中的每个元素提供当前元素及下一元素:

我们从可迭代对象中手动获取一个迭代器,在它上面调用next函数来获取第一个元素,再循环遍历迭代器以获取所有后续元素,跟踪后一个元素。此函数不仅适用于序列,还适用于任何类型的可迭代对象。

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

还要注意,这段代码已足够紧凑,如果我们愿意,我们甚至可以将方法复制到列表推导中来。

再次回顾循环陷阱

现在我们回到之前看到的那些奇怪的例子,看看到底发生了什么。

陷阱1:耗尽迭代器

这里我们有一个生成器squares:

如果我们将这个生成器传递给tuple构造函数,我们将得到一个元组:

如果用sum计算这个生成器中数字的总和,我们将得到0:

这个生成器现在是空的:因为我们已经把它耗尽了。如果我们试着再次创建一个元组,我们会得到一个空元组:

生成器是迭代器。迭代器是一次性的可迭代对象。它们就像 Hello Kitty Pez 分配器那样不能重新加载。

陷阱2:部分消耗一个迭代器

再次使用那个生成器对象 squares:

如果我们查询 9是否在 squares生成器中,我们会得到 True:

但是我们再次查询相同的问题,我们会得到 False:

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

询问迭代器中是否包含某些内容会消耗迭代器中部分元素。如果没有循环遍历迭代器,那么是没有办法知道某个东西是否在迭代器中。

陷阱3:解包是迭代
当你遍历字典时,你会获得键:

解包字典也可以获得键:

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

回顾和相关资源

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

在Python中迭代器是最基本的可迭代对象。如果你想在代码中使用惰性的可迭代对象,请考虑迭代器并考虑使用生成器函数或生成器表达式。

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

以下是关文章和视频:
    《像行家一样使用循环》(链接:https://nedbatchelder.com/text/iter.html),PyCon2013主题演讲。
    《更好地使用循环》(链接:https://www.youtube.com/watch?v=V2PkkMS2Ack),本文就是基于这个演讲整理出来的。
    《 迭代器协议:循环如何工作》(链接:http://treyhunner.com/2016/12/python-iterator-protocol-how-for-loops-work/),我写的一篇关于迭代器协议的简短文章。
    《深入理解推导式》(链接:https://www.youtube.com/watch?v=5_cJIcgM7rw),关于推导式和生成器表达式的演讲。
    《Python:Range不是迭代器》,(链接:http://treyhunner.com/2018/02/python-range-is-not-an-iterator/)我写的关于range和迭代器的文章。
    《像专家一样使用循环》(链接:https://www.youtube.com/watch?v=u8g9scXeAcI),PyCon 2017主题演讲。

本文基于作者去年在澳大利亚DjangoCon大会,PyGotham会议和North Bay Python会议上发表的关于深入理解循环的演讲。想了解更多内容,请参加将于2018年5月9日至17日在俄亥俄州哥伦布市举行的PYCON大会。


英文原文:https://opensource.com/article/18/3/loop-better-deeper-look-iteration-python
译者:钱利鹏



About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK