我们先研究一下Python的for循环,看看它们是如何工作的,原理是什么。
Python的for循环与其它语言的for循环用法不一样。在本文中,我们将深入研究Python的for循环,了解它们是在底层如何工作的,以及背后的原理。
循环陷阱
我们先从一些“陷阱”开始讲起,在了解循环的工作原理之后,我们再回头看看这些陷阱并解释发生了什么。
陷阱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
译者:钱利鹏
领取专属 10元无门槛券
私享最新 技术干货