Python自学手册
Python的for循环与其他语言中的for循环工作方式不同。在本文中,我们将深入探讨Python的for循环,了解其底层工作原理以及为何会以这种方式工作。
循环陷阱
我们将从一些“陷阱”开始我们的探索。在了解了Python中循环的工作原理后,我们会重新审视这些陷阱,并解释其中的原因。
陷阱1:循环两次
假设我们有一个数字列表和一个生成器,该生成器会返回这些数字的平方:
python>>> numbers = [1, 2, 3, 5, 7]>>> squares = (n**2 for n in numbers) |
我们可以将生成器对象传入元组构造函数,将其转换为元组:
python>>> tuple(squares)(1, 4, 9, 25, 49) |
如果我们再将同一个生成器对象传入sum函数,可能会期望得到这些数字的和,即88。
但实际上我们得到的是0。
陷阱2:包含性检查
我们使用同样的数字列表和生成器对象:
python>>> numbers = [1, 2, 3, 5, 7]>>> squares = (n**2 for n in numbers) |
如果我们询问9是否在生成器squares中,Python会告诉我们9在其中。但如果我们再次询问同样的问题,Python会告诉我们9不在其中。
python>>> 9 in squaresTrue>>> 9 in squaresFalse |
我们两次问了同样的问题,却得到了两个不同的答案。
陷阱3:解包
这个字典包含两个键值对:
python>>> counts = {'apples': 2, 'oranges': 1} |
让我们使用多重赋值解包这个字典:
你可能会期望解包字典时,会得到键值对,或者可能会报错。
但解包字典既不会报错,也不会返回键值对。解包字典时,你得到的是键:
在了解了驱动这些Python代码片段的底层逻辑后,我们会回到这些陷阱上来。
回顾:Python的for循环
Python没有传统的for循环。为了解释我的意思,让我们看看另一种编程语言中的for循环。
这是用JavaScript编写的传统C风格for循环:
javascriptlet 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循环:
pythonnumbers = [1, 2, 3, 5, 7]for n in numbers:print(n) |
与传统的C风格for循环不同,Python的for循环没有索引变量。没有索引初始化、边界检查或索引递增操作。Python的for循环会为我们完成遍历数字列表的所有工作。
因此,虽然Python中有for循环,但它并不是传统的C风格for循环。我们称之为for循环的东西,工作方式完全不同。
定义:可迭代对象与序列
现在我们已经解决了Python中无索引for循环的问题,接下来让我们明确一些定义。
可迭代对象(iterable)是指在Python中可以用for循环遍历的任何东西。可迭代对象可以被遍历,任何可以被遍历的东西都是可迭代对象。
pythonfor item in some_iterable:print(item) |
序列(sequence)是一种非常常见的可迭代对象。列表、元组和字符串都是序列。
python>>> numbers = [1, 2, 3, 5, 7]>>> coordinates = (4, 5, 7)>>> words = "hello there" |
序列是具有特定特性集的可迭代对象。它们可以从0开始索引,到序列长度减1结束,它们有长度,并且可以切片。列表、元组、字符串和所有其他序列都以这种方式工作。
python>>> numbers[0]1>>> coordinates[2]7>>> words[4]'o' |
Python中有很多东西是可迭代对象,但并非所有可迭代对象都是序列。集合、字典、文件和生成器都是可迭代对象,但它们都不是序列。
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循环和索引手动遍历一个可迭代对象:
pythonnumbers = [1, 2, 3, 5, 7]i = 0while i < len(numbers):print(numbers[i])i += 1 |
这对列表有效,但对所有东西都无效。这种循环方式只适用于序列。
如果我们尝试使用索引手动遍历一个集合,会得到一个错误:
python>>> 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循环使用迭代器(iterator)。
迭代器是驱动可迭代对象的核心。你可以从任何可迭代对象中获取迭代器。并且你可以使用迭代器手动遍历它所来自的可迭代对象。
让我们看看这是如何工作的。
这里有三个可迭代对象:一个集合、一个元组和一个字符串。
python>>> numbers = {1, 2, 3, 5, 7}>>> coordinates = (4, 5, 7)>>> words = "hello there" |
我们可以使用Python的内置iter函数,向每个可迭代对象请求一个迭代器。无论我们处理的是哪种类型的可迭代对象,将其传入iter函数总会返回一个迭代器。
python>>> iter(numbers)<set_iterator object at 0x7f2b9271c860>>>> iter(coordinates)<tuple_iterator object at 0x7f2b9271ce80>>>> iter(words)<str_iterator object at 0x7f2b9271c860> |
一旦我们有了迭代器,我们唯一能做的就是通过将其传入内置next函数来获取它的下一个元素。
python>>> numbers = [1, 2, 3]>>> my_iterator = iter(numbers)>>> next(my_iterator)1>>> next(my_iterator)2 |
迭代器是有状态的,这意味着一旦你从它们中消费了一个元素,这个元素就消失了。
如果你向迭代器请求下一个元素,但已经没有更多元素了,你会得到一个StopIteration异常:
python>>> next(iterator)3>>> next(iterator)Traceback (most recent call last):File "<stdin>", line 1, in <module>StopIteration |
因此,你可以从每个可迭代对象中获取迭代器。并且你唯一能对迭代器做的事情,就是使用next函数请求它的下一个元素。如果你将迭代器传入next函数,但它没有下一个元素,就会抛出StopIteration异常。
“Hello Kitty PEZ糖果盒”
Hello Kitty PEZ糖果盒照片由Deborah Austin拍摄 / 知识共享署名许可
你可以将迭代器想象成一个无法重新装填的Hello Kitty PEZ糖果盒。你可以取出糖果,但一旦糖果被取出就无法放回,一旦糖果盒空了,它就没用了。
不使用for循环实现循环
现在我们已经了解了迭代器以及iter和next函数,我们将尝试不使用for循环,手动遍历一个可迭代对象。
我们将尝试把这个for循环转换为while循环:
pythondef funky_for_loop(iterable, action_to_do):for item in iterable:action_to_do(item) |
为了实现这一点,我们将:
1.从给定的可迭代对象中获取一个迭代器
2.反复从迭代器中获取下一个元素
3.如果成功获取到下一个元素,执行for循环的主体
4.如果在获取下一个元素时得到StopIteration异常,停止循环
pythondef funky_for_loop(iterable, action_to_do):iterator = iter(iterable)done_looping = Falsewhile not done_looping:try:item = next(iterator)except StopIteration:done_looping = Trueelse:action_to_do(item) |
我们刚刚通过while循环和迭代器重新发明了for循环。
上面的代码基本上定义了Python中循环的底层工作方式。如果你理解内置iter和next函数在遍历事物时的工作方式,你就理解了Python的for循环是如何工作的。
事实上,你理解的不仅仅是Python中for循环的工作方式。所有遍历可迭代对象的方式都是这样工作的。
迭代器协议(iterator protocol)是“Python中如何遍历可迭代对象”的一种专业说法。它本质上定义了Python中iter和next函数的工作方式。Python中的所有迭代形式都由迭代器协议驱动。
for循环使用迭代器协议(正如我们已经看到的):
pythonfor n in numbers:print(n) |
多重赋值也使用迭代器协议:
pythonx, y, z = coordinates |
星号表达式使用迭代器协议:
pythona, b, *rest = numbersprint(*numbers) |
许多内置函数也依赖于迭代器协议:
pythonunique_numbers = set(numbers) |
Python中任何与可迭代对象相关的操作,大概率都以某种方式使用了迭代器协议。每当你在Python中遍历一个可迭代对象时,你都在依赖迭代器协议。
生成器是迭代器
所以你可能会想:迭代器看起来很酷,但它们似乎也只是一个实现细节,作为Python用户,我们可能不需要关心它们。
我要告诉你一个消息:在Python中直接使用迭代器是非常常见的。
这里的squares对象是一个生成器:
python>>> numbers = [1, 2, 3]>>> squares = (n**2 for n in numbers) |
而生成器是迭代器,这意味着你可以对生成器调用next函数来获取它的下一个元素:
python>>> next(squares)1>>> next(squares)4 |
但如果你以前使用过生成器,你可能知道你也可以遍历生成器:
python>>> squares = (n**2 for n in numbers)>>> for n in squares:... print(n)...149 |
如果在Python中你可以遍历某个东西,那么它就是一个可迭代对象。
所以生成器是迭代器,但生成器也是可迭代对象。这是怎么回事?
我之前骗了你
所以当我之前解释迭代器的工作方式时,我忽略了一个关于它们的重要细节。
迭代器是可迭代对象。
我再重复一遍:Python中的每个迭代器也是一个可迭代对象,这意味着你可以遍历迭代器。
因为迭代器也是可迭代对象,所以你可以使用内置iter函数从迭代器中获取迭代器:
python>>> numbers = [1, 2, 3]>>> iterator1 = iter(numbers)>>> iterator2 = iter(iterator1) |
记住,当我们对可迭代对象调用iter函数时,它会给我们返回一个迭代器。
当我们对迭代器调用iter函数时,它总会返回它自己:
python>>> iterator1 is iterator2True |
迭代器是可迭代对象,并且所有迭代器都是它们自己的迭代器。
pythondef is_iterator(iterable):return iter(iterable) is iterable |
是不是有点困惑?
让我们回顾一下这些术语。
可迭代对象是你能够遍历的东西。迭代器是实际执行遍历可迭代对象操作的“代理”。
此外,在Python中,迭代器也是可迭代对象,并且它们作为自己的迭代器。
所以迭代器是可迭代对象,但它们没有某些可迭代对象所具有的多种特性。
迭代器没有长度,也不能被索引:
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函数或遍历它:
python>>> next(iterator)1>>> list(iterator)[2, 3, 5, 7] |
如果我们第二次遍历迭代器,我们将什么也得不到:
python>>> list(iterator)[] |
你可以将迭代器视为“一次性的惰性可迭代对象”,这意味着它们只能被遍历一次。
对象 | 可迭代? | 迭代器? |
可迭代对象 | ✔️ | ❓ |
迭代器 | ✔️ | ✔️ |
生成器 | ✔️ | ✔️ |
列表 | ✔️ | ❌ |
正如你在上面的真值表中看到的,可迭代对象并不总是迭代器,但迭代器总是可迭代对象。
完整的迭代器协议
让我们从Python的角度定义迭代器的工作方式。
可迭代对象可以被传入iter函数,以获取其对应的迭代器。
迭代器:
•可以被传入next函数,该函数会返回其下一个元素;如果没有更多元素,则抛出StopIteration异常
•可以被传入iter函数,并且会返回其自身
这些陈述的逆命题也成立:
•任何可以被传入iter函数而不抛出TypeError异常的东西都是可迭代对象
•任何可以被传入next函数而不抛出TypeError异常的东西都是迭代器
•任何被传入iter函数时返回自身的东西都是迭代器
这就是Python中的迭代器协议。
迭代器实现惰性求值
迭代器允许我们使用和创建“惰性可迭代对象”——这种对象在你请求其下一个元素之前,不会执行任何操作。因为我们可以创建惰性可迭代对象,所以我们可以创建无限长的可迭代对象。并且我们可以创建节省系统资源的可迭代对象,它们可以为我们节省内存和CPU时间。
迭代器无处不在
你在Python中已经见过很多迭代器了。我已经提到过生成器是迭代器。Python的许多内置类也是迭代器。例如,Python的enumerate和reversed对象都是迭代器。
python>>> letters = ['a', 'b', 'c']>>> e = enumerate(letters)>>> e<enumerate object at 0x7f112b0e6510>>>> next(e)(0, 'a') |
在Python 3中,zip、map和filter对象也是迭代器。
python>>> numbers = [1, 2, 3, 5, 7]>>> letters = ['a', 'b', 'c']>>> z = zip(numbers, letters)>>> z<zip object at 0x7f112cc6ce48>>>> next(z)(1, 'a') |
Python中的文件对象也是迭代器。
python>>> next(open('hello.txt'))'hello world\n' |
Python的内置函数、标准库和第三方Python库中都有很多迭代器。这些迭代器都像惰性可迭代对象一样工作,将操作延迟到你请求其下一个元素的那一刻。
创建自己的迭代器
了解你已经在使用迭代器是很有用的,但我还希望你知道,你可以创建自己的迭代器和自己的惰性可迭代对象。
这个类创建了一个迭代器,它接收一个数字可迭代对象,并在被遍历时提供每个数字的平方。
pythonclass square_all:def __init__(self, numbers):self.numbers = iter(numbers)def __next__(self):return next(self.numbers) ** 2def __iter__(self):return self |
但在我们开始遍历这个类的实例之前,不会执行任何操作。
这里我们有一个无限长的可迭代对象count,你可以看到square_all接收count,而不需要完全遍历这个无限长的可迭代对象:
python>>> from itertools import count>>> numbers = count(5)>>> squares = square_all(numbers)>>> next(squares)25>>> next(squares)36 |
这个迭代器类是有效的,但我们通常不会以这种方式创建迭代器。通常,当我们想要创建自定义迭代器时,我们会创建一个生成器函数:
pythondef square_all(numbers):for n in numbers:yield n**2 |
这个生成器函数与我们上面创建的类等价,并且工作方式基本相同。
那个yield语句可能看起来很神奇,但它非常强大:yield允许我们在生成器函数被next函数调用之间暂停。yield语句是区分生成器函数和普通函数的关键。
实现同一个迭代器的另一种方法是使用生成器表达式。
pythondef square_all(numbers):return (n**2 for n in numbers) |
这与我们的生成器函数做同样的事情,但它使用的语法看起来像列表推导式。如果你需要在代码中创建一个惰性可迭代对象,考虑迭代器,并尝试创建生成器函数或生成器表达式。
迭代器如何改进你的代码
一旦你接受了在代码中使用惰性可迭代对象的想法,你会发现有很多可能性去发现或创建辅助函数,帮助你遍历可迭代对象和处理数据。
惰性求值与求和
这是一个求和Django查询集中所有可计费小时数的for循环:
pythonhours_worked = 0for event in events:if event.is_billable():hours_worked += event.duration |
下面的代码使用生成器表达式进行惰性求值,做了同样的事情:
pythonbillable_times = (event.durationfor event in eventsif event.is_billable())hours_worked = sum(billable_times) |
注意,我们代码的结构发生了巨大变化。
将可计费时间转换为惰性可迭代对象,让我们可以为之前未命名的东西(billable_times)命名。这也让我们能够使用sum函数。我们之前不能使用sum函数,因为我们甚至没有一个可迭代对象可以传入它。迭代器允许你从根本上改变代码的结构。
惰性求值与跳出循环
这段代码打印日志文件的前10行:
pythonfor i, line in enumerate(log_file):if i >= 10:breakprint(line) |
这段代码做了同样的事情,但我们使用itertools.islice函数在遍历过程中惰性地获取文件的前10行:
pythonfrom itertools import islicefirst_ten_lines = islice(log_file, 10)for line in first_ten_lines:print(line) |
我们创建的first_ten_lines变量是一个迭代器。再次使用迭代器,让我们可以为之前未命名的东西(前10行)命名。命名事物可以使我们的代码更具描述性和可读性。
此外,我们还不需要在循环中使用break语句,因为islice工具会为我们处理跳出逻辑。
你可以在标准库的itertools中,以及第三方库(如boltons和more-itertools)中找到更多的迭代辅助函数。
创建自己的迭代辅助函数
你可以在标准库和第三方库中找到循环辅助函数,但你也可以创建自己的辅助函数!
这段代码创建了一个列表,包含序列中连续值之间的差值。
pythoncurrent = readings[0]for next_item in readings[1:]:differences.append(next_item - current)current = next_item |
注意,这段代码有一个额外的变量,我们需要在每次循环时赋值。还要注意,这段代码只适用于可以切片的东西,比如序列。如果readings是生成器、zip对象或任何其他类型的迭代器,这段代码会失败。
让我们编写一个辅助函数来修复我们的代码。
这是一个生成器函数,它为给定可迭代对象中的每个元素,返回(当前元素,下一个元素)的元组:
pythondef 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_itemcurrent = next_item |
我们手动从可迭代对象中获取一个迭代器,调用next函数获取第一个元素,然后遍历迭代器获取所有后续元素,并在过程中跟踪最后一个元素。这个函数不仅适用于序列,还适用于任何类型的可迭代对象。
这是同样的代码,但我们使用辅助函数,而不是手动跟踪next_item:
pythondifferences = []for current, next_item in with_next(readings):differences.append(next_item - current) |
注意,这段代码没有在循环中出现尴尬的next_item赋值。with_next生成器函数为我们处理了跟踪next_item的工作。
还要注意,这段代码已经足够简洁,我们甚至可以将其改写成列表推导式(如果需要的话)。
pythondifferences = [(next_item - current)for current, next_item in with_next(readings)] |
重温循环陷阱
到这里,我们已经准备好回到之前看到的那些奇怪示例,试着弄清楚到底发生了什么。
陷阱1:耗尽迭代器
这里我们有一个生成器对象squares:
python>>> numbers = [1, 2, 3, 5, 7]>>> squares = (n**2 for n in numbers) |
如果我们将这个生成器传入元组构造函数,我们会得到一个包含其元素的元组:
python>>> numbers = [1, 2, 3, 5, 7]>>> squares = (n**2 for n in numbers)>>> tuple(squares)(1, 4, 9, 25, 49) |
如果我们然后尝试计算这个生成器中数字的和,我们会得到0:
这个生成器现在是空的:我们已经耗尽了它。如果我们再次尝试将其转换为元组,我们会得到一个空元组:
python>>> tuple(squares)() |
生成器是迭代器。而迭代器是一次性的可迭代对象。它们就像无法重新装填的Hello Kitty PEZ糖果盒。
陷阱2:部分消费迭代器
我们再次使用生成器对象squares:
python>>> numbers = [1, 2, 3, 5, 7]>>> squares = (n**2 for n in numbers) |
如果我们询问9是否在生成器squares中,我们会得到True:
python>>> 9 in squaresTrue |
但如果我们再次询问同样的问题,我们会得到False:
python>>> 9 in squaresFalse |
当我们询问9是否在这个生成器中时,Python必须遍历这个生成器来寻找9。如果我们在检查9之后继续遍历它,我们只会得到最后两个数字,因为我们已经消费了在此之前的数字:
python>>> numbers = [1, 2, 3, 5, 7]>>> squares = (n**2 for n in numbers)>>> 9 in squaresTrue>>> list(squares)[25, 49] |
询问某个元素是否包含在迭代器中,会部分消费该迭代器。不开始遍历迭代器,就无法知道某个元素是否在其中。
陷阱3:解包就是迭代
当你遍历字典时,你得到的是键:
python>>> counts = {'apples': 2, 'oranges': 1}>>> for key in counts:... print(key)...applesoranges |
解包字典时,你得到的也是键:
python>>> x, y = counts>>> x, y('apples', 'oranges') |
循环依赖于迭代器协议。可迭代对象解包也依赖于迭代器协议。解包字典实际上和遍历字典是一样的。两者都使用迭代器协议,所以在两种情况下你得到的结果是相同的。
总结与相关资源
序列是可迭代对象,但并非所有可迭代对象都是序列。当有人说“可迭代对象”时,你只能假设他们指的是“可以被迭代的东西”。不要假设可迭代对象可以被循环两次、可以获取长度或可以被索引。
迭代器是Python中最基本的可迭代对象形式。如果你想在代码中创建一个惰性可迭代对象,考虑迭代器,并尝试创建生成器函数或生成器表达式。
最后,请记住,Python中的所有迭代类型都依赖于迭代器协议,因此理解迭代器协议是理解Python中循环机制的关键。