为什么不能对同一个迭代器进行两次迭代?如何“重置”迭代器或重复使用数据?

90

考虑以下代码:

def test(data):
    for row in data:
        print("first loop")
    for row in data:
        print("second loop")

data 是一个迭代器,例如列表迭代器或生成器表达式*时,以下方法不起作用:

>>> test(iter([1, 2]))
first loop
first loop
>>> test((_ for _ in [1, 2]))
first loop
first loop

这会打印出first loop几次,因为data非空。但是,它不会打印出second loop为什么第一次迭代data可以工作,而第二次却不行?我该如何让它第二次工作?

除了for循环之外,任何类型的迭代都会出现相同的问题:列表/集合/字典推导式,将迭代器传递给list()sum()reduce()等函数。

另一方面,如果data是另一种可迭代对象,比如一个list或者一个range(两者都是序列),两个循环都按预期运行:

>>> test([1, 2])
first loop
first loop
second loop
second loop
>>> test(range(2))
first loop
first loop
second loop
second loop

* 更多示例:


有关一般理论和术语解释,请参见什么是迭代器、可迭代对象和迭代?

检测输入是迭代器还是“可重复使用”的可迭代对象,请参见确保某个参数可以被迭代两次


12
Iterable与iterator的区别。可迭代对象(Iterable)与迭代器(iterator)是两个不同的概念。可迭代对象(Iterable)是指那些可以被迭代的对象,比如列表、元组、集合等。它们都可以用 for 循环进行遍历。而迭代器(Iterator)是一种访问集合内元素的方式,它提供了一种方法按需惰性地获取元素,这样不必事先准备好所有要处理的元素,可以节省内存空间和计算资源。迭代器使用 next() 方法逐一访问集合中的元素,如果没有更多元素,则会引发 StopIteration 异常。需要注意的是,一个可迭代对象可以通过调用其内置的 iter() 函数返回一个对应的迭代器对象。例如,调用列表对象的 iter() 方法将返回一个对应的迭代器对象,该对象可以通过调用 next() 方法来逐一访问列表中的元素。 - Ignacio Vazquez-Abrams
3
这个问题中提供的代码并不是重新创建问题所需的最短代码。通过提供更好的代码示例,可以改进这个问题。 - Trilarion
@Trilarion 是的,我认为可以安全地删除 def _view(self,dbName): db = self.dictDatabases[dbName] data = db[3] 部分,因为没有其他答案讨论该代码部分。 - Mateen Ulhaq
我认为这里有一个未解答的问题,这个问题可能会让初学者困惑:“我如何判断我的数据是迭代器还是可迭代的?”例如,为什么我可以两次遍历这个列表,但不能两次遍历这个文件? - AShelly
2
@AShelly,我找到了一个涉及到那个内容的问题,并加入了一个“参见链接”。 - Karl Knechtel
显示剩余4条评论
6个回答

68

迭代器只能被消耗一次。例如:

lst = [1, 2, 3]
it = iter(lst)

next(it)
# => 1
next(it)
# => 2
next(it)
# => 3
next(it)
# => StopIteration
当将迭代器提供给 for 循环时,最后一个 StopIteration 将会导致循环第一次退出。尝试在另一个 for 循环中使用同一迭代器将立即再次导致 StopIteration,因为迭代器已经被消耗了。
一个简单的解决方法是将所有元素保存到列表中,该列表可以遍历多次。例如:
data = list(data)

如果迭代器需要遍历许多元素,最好使用tee()创建独立的迭代器:

import itertools
it1, it2 = itertools.tee(data, 2) # create as many as needed

现在每个都可以依次迭代:

for e in it1:
    print("first loop")

for e in it2:
    print("second loop")

26
来自tee文档的注释:“这个迭代工具可能需要大量辅助存储(取决于需要存储多少临时数据)。通常,如果一个迭代器在另一个迭代器开始之前使用了大部分或全部数据,则使用list()比使用tee()更快。”所以,如果您像示例中一样使用it1it2,那么您可能不会从tee中获得任何真正的好处(同时可能会增加一些额外的开销)。 - svk
14
我支持@svk - 在这种情况下,tee将以略微低效的方式创建迭代器值的完整副本,而不是单个list调用。当可迭代对象中有许多元素时,不应该使用tee - 这与本案例无关,但在使用局部性时应该使用它 - 在这种情况下,tee的缓存可以小于整个列表。例如,如果两个迭代器并驾齐驱,就像在zip(a, islice(b, 1))调用中一样。 - shitpoet
13
您编辑的这个回答正在meta上被讨论。 - cigien

34

迭代器(例如通过调用 iter, 生成器表达式或使用yield的生成器函数产生的迭代器)是有状态的,只能消耗一次。

这在Óscar López's answer中解释过了,然而,该回答建议为了性能原因使用itertools.tee(data)而不是list(data)是错误的。 在大多数情况下,如果想要遍历整个data,然后再次完整地遍历它,那么使用tee比将整个迭代器消耗到一个列表中并两次迭代更耗时并且使用更多内存。根据文档

此迭代工具可能需要大量辅助存储(取决于需要存储多少临时数据)。通常,如果一个迭代器在另一个迭代器开始之前使用了大部分或全部数据,则使用list()而不是tee()更快。

如果您只消耗每个迭代器的前几个元素,或者您将在一个迭代器中消耗几个元素然后再消耗另一个迭代器的几个元素之间切换,则可能更喜欢使用tee


如果能提供一些具体的分析结果或对工作中所需完成的任务“tee”进行理论考察,就更加有说服力了,而不是创建一个辅助列表。 - Karl Knechtel
@KarlKnechtel 这个说法来自文档 - 我已经编辑过了,包括了引用和链接。我同意进行一些实证分析也会有所改进。 - kaya3

13

一旦迭代器耗尽,它将不再产生任何输出。

>>> it = iter([3, 1, 2])
>>> for x in it: print(x)
...
3
1
2
>>> for x in it: print(x)
...
>>>

4
明白了,但我该怎样规避这个问题? - JSchwartz
@JSchwartz,将迭代器转换为序列对象(listtuple)。然后迭代序列对象。(仅当csv文件大小不是很大时) - falsetru
4
如果您可以访问基础文件对象并且该对象支持随机访问,您可以在第二个循环之前更改文件位置:csv_file_object.seek(0)。请注意,不要改变原来的意思。 - falsetru
这个答案已经过时了,因为我试图将问题改进为一个规范(在尽可能清晰地解释问题并给出具体示例之后,答案现在重复了问题中存在的信息)。对此感到抱歉。 - Karl Knechtel

10

如何对迭代器进行两次循环?

通常是不可能的(稍后会解释)。相反,可以采取以下任一方法:

  • 将迭代器收集到可以多次循环的对象中。

    items = list(iterator)
    
    for item in items:
        ...
    

    缺点:这会消耗内存。

  • 创建一个新的迭代器。通常只需要一微秒来创建一个新的迭代器。

    for item in create_iterator():
        ...
    
    for item in create_iterator():
        ...
    

    缺点:迭代本身可能是昂贵的(例如从磁盘或网络读取)。

  • 重置“迭代器”。例如,对于文件迭代器:

    with open(...) as f:
        for item in f:
            ...
    
        f.seek(0)
    
        for item in f:
            ...
    

    缺点:大多数迭代器无法“重置”。


迭代器的哲学

通常情况下,虽然不是严格意义上的1

  • 可迭代对象:表示数据的可用于for循环的对象。例如:listtuplestr
  • 迭代器:指向可迭代对象中某个元素的指针。

如果我们要定义一个序列迭代器,可能会像这样:

class SequenceIterator:
    index: int
    items: Sequence  # Sequences can be randomly indexed via items[index].

    def __next__(self):
        """Increment index, and return the latest item."""

重要的是,通常情况下,迭代器本身不存储任何实际数据。
迭代器通常模拟一个临时的“流”数据。这些数据源被迭代的过程所消耗。这是为什么我们不能对任意数据源进行多次循环的一个很好的提示。我们需要打开一个新的临时数据流(即创建一个新的迭代器)来实现这一点。
耗尽迭代器
当我们从迭代器中提取项目时,从当前元素开始,并继续直到完全耗尽,会发生什么?这就是for循环所做的事情。
iterable = "ABC"
iterator = iter(iterable)

for item in iterator:
    print(item)

让我们通过告诉“for”循环如何提取“next”项来支持SequenceIterator中的这个功能。
class SequenceIterator:
    def __next__(self):
        item = self.items[self.index]
        self.index += 1
        return item

等一下。如果index超过了items的最后一个元素怎么办?我们应该为此抛出一个安全异常:
class SequenceIterator:
    def __next__(self):
        try:
            item = self.items[self.index]
        except IndexError:
            raise StopIteration  # Safely says, "no more items in iterator!"
        self.index += 1
        return item

现在,for循环知道何时停止从迭代器中提取项目。

如果我们现在再次尝试循环迭代器,会发生什么?

iterable = "ABC"
iterator = iter(iterable)

# iterator.index == 0

for item in iterator:
    print(item)

# iterator.index == 3

for item in iterator:
    print(item)

# iterator.index == 3

由于第二个循环从当前的iterator.index开始,即3,它没有其他内容可打印,因此iterator.__next__引发了StopIteration异常,导致循环立即结束。

1 技术上:

  • 可迭代对象:当调用__iter__时返回一个迭代器的对象。
  • 迭代器:一个可以在循环中重复调用__next__以提取项目的对象。此外,调用__iter__应该返回它自己self

更多细节在这里


这里有很多好的信息,但也有一些小的技术不准确之处。我开始尝试编辑它,但最终决定重新开始,用完全不同的方式组织材料,以达到更好的效果。 - Karl Knechtel

3

为什么迭代器第二次不起作用?

它是“工作的”,在示例中的for循环确实运行。 只是执行了零次迭代。这是因为迭代器已经“耗尽”了; 它已经迭代完所有元素。

为什么其他类型的可迭代对象可以工作?

因为在幕后,每个循环都基于该可迭代对象创建了一个新的迭代器。从头开始创建迭代器意味着它从开头开始。

这是因为迭代需要可迭代对象。如果已经提供了可迭代对象,则将其按原样使用;否则,需要进行转换,从而创建一个新对象。

给定一个迭代器,如何两次迭代数据?

通过缓存数据;使用新迭代器重新开始(假设我们可以重新创建初始条件);或者,如果迭代器专门设计用于此目的,则寻找或重置迭代器。相对较少的迭代器提供寻找或重置功能。

缓存

唯一完全通用的方法是记住第一次看到了哪些元素(或确定将要看到哪些元素),然后再次迭代它们。最简单的方法是通过从迭代器创建一个列表或元组:
elements = list(iterator)
for element in elements:
    ...

for element in elements:
    ...

由于列表是非迭代器可迭代对象,每个循环都会创建一个新的可迭代对象,用于遍历所有元素。如果在此之前迭代器已经“部分完成”了一个迭代过程,则该列表仅包含“后续”的元素:
abstract = (x for x in range(10)) # represents integers from 0 to 9 inclusive
next(abstract) # skips the 0
concrete = list(abstract) # makes a list with the rest
for element in concrete:
    print(element) # starts at 1, because the list does

for element in concrete:
    print(element) # also starts at 1, because a new iterator is created

一种更复杂的方法是使用 itertools.tee。它基本上创建了一个“缓冲区”,在迭代原始源时从中获取元素,然后创建并返回多个自定义迭代器,这些迭代器通过记住索引、在可能的情况下从缓冲区获取数据,并在必要时(使用原始可迭代对象)将数据添加到缓冲区中来工作。(在现代 Python 版本的参考实现中,这不使用本地 Python 代码。)
from itertools import tee
concrete = list(range(10)) # `tee` works on any iterable, iterator or not
x, y = tee(concrete, 2) # the second argument is the number of instances.
for element in x:
    print(element)
    if element == 3:
        break

for element in y:
    print(element) # starts over at 0, taking 0, 1, 2, 3 from a buffer

重新开始

如果我们知道并能重新创建迭代器启动时的起始条件,那么这也解决了问题。当多次在列表上进行迭代时,这就是隐含发生的事情:“迭代器的起始条件”只是列表的内容,从中创建的所有迭代器都会给出相同的结果。例如,如果生成器函数不依赖于外部状态,我们可以简单地使用相同的参数再次调用它:

def powers_of(base, *range_args):
    for i in range(*range_args):
        yield base ** i

exhaustible = powers_of(2, 1, 12):

for value in exhaustible:
    print(value)

print('exhausted')

for value in exhaustible: # no results from here
    print(value)

# Want the same values again? Then use the same generator again:
print('replenished')
for value in powers_of(2, 1, 12):
    print(value)

可寻址或可重置的迭代器

一些特定的迭代器可能会使得重置迭代到开头,甚至可以“寻址”到迭代中的特定位置成为可能。一般来说,迭代器需要具有某种内部状态以便跟踪它们在迭代中的“位置”。使迭代器“可寻址”或“可重置”只是意味着允许外部访问相应的状态进行修改或重新初始化。

Python 中没有任何禁止这样做的规定,但在许多情况下提供简单的接口是不可行的在大多数其他情况下,即使这很容易,也不支持此操作。对于生成器函数,相应的内部状态非常复杂,并且保护自己免受修改。

可寻址迭代器的经典示例是使用内置的open函数创建的打开的文件对象。所谓的状态是指磁盘上底层文件中的位置;.tell.seek方法允许我们检查和修改该位置的值——例如,.seek(0)将把位置设置为文件的开头,有效地重置迭代器。同样,csv.reader是一个文件的包装器;在该文件中进行搜索将影响迭代的后续结果。
在除了最简单、经过特别设计的情况下,迭代器倒回可能会非常困难,甚至不可能。即使迭代器被设计为可寻址,这也留下了一个问题:如何确定要寻址到哪里 - 即,在迭代中所需点的内部状态是什么。对于上面展示的 powers_of 生成器来说,这很简单:只需修改 i。对于文件来说,我们需要知道所需行的开头的文件位置,而不仅仅是行号。这就是为什么文件接口提供了 .tell.seek 的原因。
下面是一个重新设计的 powers_of 示例,表示一个无限序列,并设计为可寻址、可倒回和可通过 exponent 属性重置:
class PowersOf:
    def __init__(self, base):
        self._exponent = 0
        self._base = base
    def __iter__(self):
        return self
    def __next__(self):
        result = self._base ** self._exponent
        self._exponent += 1
        return result
    @property
    def exponent(self):
        return self._exponent
    @exponent.setter
    def exponent(self, value):
        if not isinstance(new_value, int):
            raise TypeError("must set with an integer")
        if new_value < 0:
            raise ValueError("can't set to negative value")
        self._exponent = new_value

例子:
pot = PowersOf(2)
for i in pot:
    if i > 1000:
        break
    print(i)

pot.exponent = 5 # jump to this point in the (unbounded) sequence
print(next(pot)) # 32
print(next(pot)) # 64

技术细节

迭代器 vs. 可迭代对象

回想一下,简单来说:

"iteration" 意味着逐一查看某个抽象的、概念上的值序列中的每个元素。这可以包括: "iterable" 意味着表示这样一个序列的对象。(Python 文档所称的“序列”实际上比这更具体——基本上还需要是有限的和有序的。)请注意,元素不需要被“存储”——在内存、磁盘或任何其他地方;我们可以在迭代过程中确定它们即可。 "iterator" 意味着表示迭代过程的对象;在某种意义上,它跟踪“我们在迭代中的位置”。
将两个定义合并,可迭代对象是指代表可以按照特定顺序检查的元素;而迭代器是允许我们按照特定顺序检查元素的东西。当然,迭代器“代表”这些元素——因为我们可以通过检查它们来找出它们是什么——而且它们可以按照特定顺序检查——因为这就是迭代器所能实现的。因此,我们可以得出结论:迭代器是可迭代对象的一种,Python的定义也是如此。
迭代需要一个迭代器。在Python中进行迭代时,需要使用迭代器;但在正常情况下(即除了用户编写不良代码之外),任何可迭代对象都是允许的。在幕后,Python会将其他可迭代对象转换为相应的迭代器;这方面的逻辑可通过内置的iter函数获得。要进行迭代,Python会反复向迭代器请求“下一个元素”,直到迭代器引发StopException异常。这方面的逻辑可通过内置的next函数获得。
通常情况下,当给iter传递一个已经是迭代器的单一参数时,它将原样返回同一对象。但如果是其他类型的可迭代对象,将创建一个新的迭代器对象。这直接导致了OP中的问题。用户定义的类型可能会违反这两个规则,但最好不要这样做。
迭代器协议
Python粗略地定义了一个"迭代器协议",指定了它如何确定一个类型是否为可迭代(或具体为迭代器),以及类型如何提供迭代功能。细节在过去的几年中发生了一些变化,但现代设置的工作方式如下:
  • 任何具有 __iter__ 方法或 __getitem__ 方法的对象都是可迭代的。任何定义了 __iter__ 方法和 __next__ 方法的对象都是迭代器。 (特别注意,如果存在 __getitem____next__ 但不存在 __iter__,那么 __next__ 就没有特定的意义,该对象就是一个非迭代器可迭代对象。)

  • 如果给出单个参数,iter 将尝试调用该参数的 __iter__ 方法,验证其结果具有 __next__ 方法,并返回该结果。它不会确保结果上存在 __iter__ 方法。这种对象通常可以在期望迭代器的位置使用,但如果对它们调用 iter,则会失败。如果没有 __iter__,则会查找 __getitem__,并使用它来创建内置迭代器类型的实例。该迭代器大致等同于

class Iterator:
    def __init__(self, bound_getitem):
        self._index = 0
        self._bound_getitem = bound_getitem
    def __iter__(self):
        return self
    def __next__(self):
        try:
            result = self._bound_getitem(self._index)
        except IndexError:
            raise StopIteration
        self._index += 1
        return result

给定一个参数,next 将尝试调用该参数的 __next__ 方法,允许任何 StopIteration 传播。
有了这些机制,可以通过 while 实现 for 循环。具体来说,像下面这样的循环:
for element in iterable:
    ...

将近的意思是:
iterator = iter(iterable)
while True:
    try:
        element = next(iterator)
    except StopIteration:
        break
    ...

除了迭代器实际上没有被赋予任何名称外(这里的语法强调了即使没有迭代“...”代码也会调用一次iter)。

我最终提供了比计划中更多的细节,但重要的要点都在前面。 - Karl Knechtel

1
其他答案都是正确的,但还有一个选项没有被明确提出。它可能有点突破常规,但有些情况需要非正常解决方案。
比如说你得到了一个像这样的函数,你不允许修改:
def do_something(items):
    items_copy = list(items)
    
    for item in items:
        ...  # actual work

此函数多次迭代items参数,因此items只能是一个有大小的集合(如列表、元组或集合),以达到所需的结果,否则在调用list后迭代器将被耗尽。因此,在不重写函数的情况下,向for循环提供自定义迭代器(例如在每次迭代时前进的进度条)似乎不可行。

或者呢?让我们创建一个简单的自定义迭代器,它包装了一些迭代器并依次返回它们:

class StaggeredChain:
    def __init__(self, *iters):
        self.iters = iter(iters)
    
    def __iter__(self):
        return iter(next(self.iters, ()))

请注意,这与itertools.chain不同,它可以多次迭代,并且在每一步中的行为类似于相应的单个包装迭代器。
>>> chained = StaggeredChain(range(5), range(4, -1, -1))
>>> list(chained)
[0, 1, 2, 3, 4]
>>> list(chained)
[4, 3, 2, 1, 0]
>>> list(chained)
[]

通过这个类,我们可以实现将进度条添加到内部循环的目标:
>>> from tqdm import tqdm
>>> vals = range(5)
>>> do_something(StaggeredChain(vals, tqdm(vals)))
100%|█████████████████████████████████|

(旁白:tqdm在这种情况下将看到第一次迭代从其自己的构造函数开始,直到循环的第一次迭代结束,这可能比仅仅是循环迭代时间长得多。理想情况下,您希望延迟进度条的初始化,直到实际next了该生成器,但这是一个tqdm特定的细节。一种方法是更改StaggeredChain的构造函数为__init__(self, iters)并传入生成各个迭代器的单个参数。)

如果要求仅重复给定的一组值若干次,然后停止,则可以这样做:

import itertools

class StaggeredRepeat:
    def __init__(self, vals, loops=1):
        self.iters = itertools.repeat(tuple(vals), loops)
    
    def __iter__(self):
        return iter(next(self.iters, ()))

现在,您可以按所需次数迭代给定的集合:
>>> rep = StaggeredRepeat(range(5), 2)
>>> list(rep)
[0, 1, 2, 3, 4]
>>> list(rep)
[0, 1, 2, 3, 4]
>>> list(rep)
[]

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接