如何在Python生成器中向前查看一个元素(peek)?

106

我不知道如何在Python生成器中预测下一个元素。一旦我看到它,它就消失了。

这就是我的意思:

gen = iter([1,2,3])
next_value = gen.next()  # okay, I looked forward and see that next_value = 1
# but now:
list(gen)  # is [2, 3]  -- the first value is gone!

这里是一个更真实的例子:

gen = element_generator()
if gen.next_value() == 'STOP':
  quit_application()
else:
  process(gen.next())

有人可以帮我编写一个能够向前查看一个元素的生成器吗?


另请参阅: 如何在Python中重置生成器对象


1
你能详细描述一下你想做什么吗?可以提供代码示例吗? - Tim Pietzcker
我仍然不明白那有什么好处。如果生成器已经“耗尽”,则要避免调用gen.next()。这就是异常StopIteration的作用。在元素“STOP”之后生成器中有什么?为什么生成器在此时不会被简单地耗尽?或者,如果不可能,为什么不让生成器在当前元素为“Stop”时raise StopIteration - Tim Pietzcker
我开始认为正确的方法是修改算法,使用生成器的“当前”和“前一个”值,而不是尝试使用“下一个”和“当前”值。我相信没有任何算法不能以这种方式重构,这比提供的任何解决方案都要简单(包括我的两个解决方案)。 - Jonathan Hartley
我认为如果Python强制你改变一个完全合理的算法才能让它工作,那就是它的限制。但是,嘿,这是语言哲学的一部分。 - Steven Lu
@StevenLu 这太疯狂了!问题不在于语言,而在于OP有一个不可寻址流的两个不同读取器的糟糕想法,然后通过尝试使它们不互相干扰来弥补这种糟糕的设计。从生成器中只使用一个读取器,而不是两个,所有这些问题都会消失,不需要任何代码。 - Jonathan Hartley
显示剩余6条评论
18个回答

105

为了完整起见,more-itertools 软件包(应该是任何Python程序员工具箱的一部分)包括一个peekable包装器,实现了这种行为。正如文档中所示的代码示例:

>>> p = peekable(['a', 'b'])
>>> p.peek()
'a'
>>> next(p)
'a'

然而,通常可以重写使用此功能的代码,以使其实际上不需要它。例如,您在问题中提供的实际代码示例可以这样编写:

gen = element_generator()
command = gen.next_value()
if command == 'STOP':
  quit_application()
else:
  process(command)

(读者注:我保留了这个问题示例中的语法,尽管它引用了 Python 的一个过时版本)


80

Python的生成器API是一个方法:你不能将已读取的元素推回去。但是,你可以使用itertools模块创建一个新的迭代器,并在其前面加上元素:

import itertools

gen = iter([1,2,3])
peek = gen.next()
print list(itertools.chain([peek], gen))

9
你可以使用send来将之前生成器中已经yield的值重新推回生成器,以便在生成下一个值时使用。 - dansalmo
3
是的,但是您需要修改生成器代码。请参考Andrew Hare的答案。 - Aaron Digulla
10
我已经多次使用这个解决方案,但我认为应该指出,对于你从可迭代对象中获取的每个元素,基本上你调用itertools.chain.__next__ n次(其中n是你偷看的次数)。这对于一两次偷看来说很好,但如果你需要偷看每个元素,那么这不是最好的解决方案 :-) - mgilson
10
我提一句,这个功能在 more-itertools 包中被称为 spy。虽然这个单独的功能可能不值得引入一个全新的包,但有些人可能会发现现有的实现很有用。 - David Z
3
是的,这绝对需要附上警告。人们可能会尝试在循环中执行此操作,查看每个元素,然后整个迭代将花费平方级的时间。 - Kelly Bundy
显示剩余3条评论

24

好的-虽然晚了两年-但我看到了这个问题,并没有找到任何让我满意的答案。于是我想出了这个元生成器:

class Peekorator(object):

    def __init__(self, generator):
        self.empty = False
        self.peek = None
        self.generator = generator
        try:
            self.peek = self.generator.next()
        except StopIteration:
            self.empty = True

    def __iter__(self):
        return self

    def next(self):
        """
        Return the self.peek element, or raise StopIteration
        if empty
        """
        if self.empty:
            raise StopIteration()
        to_return = self.peek
        try:
            self.peek = self.generator.next()
        except StopIteration:
            self.peek = None
            self.empty = True
        return to_return

def simple_iterator():
    for x in range(10):
        yield x*3

pkr = Peekorator(simple_iterator())
for i in pkr:
    print i, pkr.peek, pkr.empty

结果是:

0 3 False
3 6 False
6 9 False
9 12 False    
...
24 27 False
27 None False

即,您在迭代期间随时可以访问列表中的下一个项。


5
我觉得说出这句话有点刻薄,但我认为这个解决方案很糟糕且容易出错。在任何时候,你需要从生成器中访问两个元素:“i”和“i + 1”。为什么不编写算法来使用当前值和上一个值,而不是下一个值和当前值呢?它看起来完全相同,而且比这个方法简单多了。 - Jonathan Hartley
1
尽管放心,你可以尽情地使用任何手段 :) - plof
8
@Jonathan,在复杂的例子中,例如将迭代器传递到函数中时,这可能并非总是可行。 - Florian Ledermann
4
有人应该指出,从Python2.6开始,获取生成器的下一个值的首选方法是next(generator)而不是generator.next()。我记得在Python3.x中,generator.next()将被弃用。同样,为了最佳向前兼容性,在类的主体中添加__next__ = next,以使其在Python 3.x中继续工作。话虽如此,回答很好。 - mgilson
Echoing @mgilson, 如果生成器是一个字符串迭代器,这在Python 3中将无法使用。对于这种情况,您绝对需要使用 next() - jpyams

21

使用 itertools.tee 将生成器生成一个轻量级的副本;然后查看其中一个副本不会影响第二个副本。 因此:

import itertools

def process(seq):
    peeker, items = itertools.tee(seq)
    
    # initial peek ahead
    # so that peeker is one ahead of items
    if next(peeker) == 'STOP':
        return
    
    for item in items:
    
        # peek ahead
        if next(peeker) == "STOP":
            return
    
        # process items
        print(item)

items 生成器不受对 peeker 的修改影响。然而,在调用 tee 后修改 seq 可能会导致问题。

话虽如此:任何需要查看生成器中下一项的算法都可以改为使用当前生成器项和前一个项。这将导致更简单的代码 - 请参见我在此问题的其他答案。


4
任何需要在生成器中向前查看1个项目的算法都可以改写为使用当前的生成器项目和前一个项目。对生成器的错误使用有时会导致更优雅且易读的代码,特别是在需要前瞻的解析器中。 - Rufflewind
嘿,Rufflewind。我理解解析需要前瞻的观点,但我不明白为什么你不能通过简单地存储生成器中的上一个项目,并使用生成器中最近的项目作为前瞻来实现。然后你就可以得到两全其美的效果:未被破坏的生成器和简单的解析器。 - Jonathan Hartley
这就是为什么你要将生成器包装在自定义类中以自动执行此操作。 - Rufflewind
嗨,Ruffelwind。我不再确定我理解你在主张什么了。很抱歉我已经跟不上你的思路了。 - Jonathan Hartley
1
顺便说一句,代码现在已经修复了,@Eric May的评论整个迭代器都被缓冲的说法不再正确。 - Jonathan Hartley
显示剩余2条评论

10

一个允许查看下一个元素以及更多后续元素的迭代器。它会根据需要向前读取并在deque中记住值。

from collections import deque

class PeekIterator:

    def __init__(self, iterable):
        self.iterator = iter(iterable)
        self.peeked = deque()

    def __iter__(self):
        return self

    def __next__(self):
        if self.peeked:
            return self.peeked.popleft()
        return next(self.iterator)

    def peek(self, ahead=0):
        while len(self.peeked) <= ahead:
            self.peeked.append(next(self.iterator))
        return self.peeked[ahead]

演示:

>>> it = PeekIterator(range(10))
>>> it.peek()
0
>>> it.peek(5)
5
>>> it.peek(13)
Traceback (most recent call last):
  File "<pyshell#68>", line 1, in <module>
    it.peek(13)
  File "[...]", line 15, in peek
    self.peeked.append(next(self.iterator))
StopIteration
>>> it.peek(2)
2
>>> next(it)
0
>>> it.peek(2)
3
>>> list(it)
[1, 2, 3, 4, 5, 6, 7, 8, 9]
>>>

7
一个简单的解决方案是使用像这样的函数:
def peek(it):
    first = next(it)
    return first, itertools.chain([first], it)

那么你可以执行以下操作:

>>> it = iter(range(10))
>>> x, it = peek(it)
>>> x
0
>>> next(it)
0
>>> next(it)
1

6
>>> gen = iter(range(10))
>>> peek = next(gen)
>>> peek
0
>>> gen = (value for g in ([peek], gen) for value in g)
>>> list(gen)
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

你介意解释一下这里正在发生什么吗? - Kristof Pal
我们先取出gen的第一个元素,然后创建一个可迭代对象[peek],并将其与剩余的gen合并以创建一个新的gen。这是通过迭代两个生成器的平铺来完成的,这两个生成器组合在一起形成原始的生成器。请参见平铺:https://dev59.com/qnNA5IYBdhLWcg3wdtld - Rusty Rob
1
这与itertools.chain解决方案相同,但更加明确。 - Theo Belaire

6

仅仅为了好玩,我基于Aaron的建议创建了一个前瞻类的实现:

import itertools

class lookahead_chain(object):
    def __init__(self, it):
        self._it = iter(it)

    def __iter__(self):
        return self

    def next(self):
        return next(self._it)

    def peek(self, default=None, _chain=itertools.chain):
        it = self._it
        try:
            v = self._it.next()
            self._it = _chain((v,), it)
            return v
        except StopIteration:
            return default

lookahead = lookahead_chain

有了这个,以下内容将会正常工作:

>>> t = lookahead(xrange(8))
>>> list(itertools.islice(t, 3))
[0, 1, 2]
>>> t.peek()
3
>>> list(itertools.islice(t, 3))
[3, 4, 5]

使用此实现连续多次调用peek是一个不好的主意...

我在查看CPython源代码时发现了一种更好的方法,既短又高效:

class lookahead_tee(object):
    def __init__(self, it):
        self._it, = itertools.tee(it, 1)

    def __iter__(self):
        return self._it

    def peek(self, default=None):
        try:
            return self._it.__copy__().next()
        except StopIteration:
            return default

lookahead = lookahead_tee

使用方法与上面相同,但您不需要在此处支付任何费用以连续多次使用peek。通过添加几行代码,您还可以向前查看迭代器中超过一个项(最多可达可用RAM)。


4
如果有人感兴趣的话,我认为可以很容易地向任何迭代器添加一些回推功能。请纠正我如果我错了。
class Back_pushable_iterator:
    """Class whose constructor takes an iterator as its only parameter, and
    returns an iterator that behaves in the same way, with added push back
    functionality.

    The idea is to be able to push back elements that need to be retrieved once
    more with the iterator semantics. This is particularly useful to implement
    LL(k) parsers that need k tokens of lookahead. Lookahead or push back is
    really a matter of perspective. The pushing back strategy allows a clean
    parser implementation based on recursive parser functions.

    The invoker of this class takes care of storing the elements that should be
    pushed back. A consequence of this is that any elements can be "pushed
    back", even elements that have never been retrieved from the iterator.
    The elements that are pushed back are then retrieved through the iterator
    interface in a LIFO-manner (as should logically be expected).

    This class works for any iterator but is especially meaningful for a
    generator iterator, which offers no obvious push back ability.

    In the LL(k) case mentioned above, the tokenizer can be implemented by a
    standard generator function (clean and simple), that is completed by this
    class for the needs of the actual parser.
    """
    def __init__(self, iterator):
        self.iterator = iterator
        self.pushed_back = []

    def __iter__(self):
        return self

    def __next__(self):
        if self.pushed_back:
            return self.pushed_back.pop()
        else:
            return next(self.iterator)

    def push_back(self, element):
        self.pushed_back.append(element)

it = Back_pushable_iterator(x for x in range(10))

x = next(it) # 0
print(x)
it.push_back(x)
x = next(it) # 0
print(x)
x = next(it) # 1
print(x)
x = next(it) # 2
y = next(it) # 3
print(x)
print(y)
it.push_back(y)
it.push_back(x)
x = next(it) # 2
y = next(it) # 3
print(x)
print(y)

for x in it:
    print(x) # 4-9

4
这将起作用 - 它会缓冲一个项目并调用一个函数,该函数使用序列中的每个项目和下一个项目。但是你的要求不太清楚,在序列末尾会发生什么。当你到达最后一个时,“向前查看”是什么意思?
def process_with_lookahead( iterable, aFunction ):
    prev= iterable.next()
    for item in iterable:
        aFunction( prev, item )
        prev= item
    aFunction( item, None )

def someLookaheadFunction( item, next_item ):
    print item, next_item

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