如何检查可迭代对象是否允许多次遍历?

10
在Python 3中,我该如何检查一个对象是否是容器(而不是迭代器,可能仅允许一次遍历)?
以下是一个示例:
def renormalize(cont):
    '''
    each value from the original container is scaled by the same factor
    such that their total becomes 1.0
    '''
    total = sum(cont)
    for v in cont:
        yield v/total

list(renormalize(range(5))) # [0.0, 0.1, 0.2, 0.3, 0.4]
list(renormalize(k for k in range(5))) # [] - a bug!

显然,当renormalize函数接收到一个生成器表达式时,它不能按预期工作。它假设可以多次迭代容器,而生成器只允许一次遍历。

理想情况下,我希望能够这样做:

def renormalize(cont):
    if not is_container(cont):
      raise ContainerExpectedException
    # ...

如何实现 is_container
我想我可以在第二次遍历开始时检查参数是否为空。但是对于更复杂的函数,不明确什么时候开始第二次遍历,这种方法就行不通了。此外,我宁愿在函数入口处进行验证,而不是在函数内部深处进行验证(并且每当修改函数时都要移动它)。
当然,我可以重写 renormalize 函数以正确使用单遍迭代器。但这需要将输入数据复制到容器中。复制数百万个大列表“以防万一它们不是列表”的性能影响是荒谬的。
编辑:我的原始示例使用了一个 weighted_average 函数:
def weighted_average(c):
    '''
    returns weighted average of a container c
    c contains values and weights in tuples
    weights don't need to sum up 1 (automatically renormalized)
    '''
    return sum((v * w for v, w in c)) / sum((w for v, w in c))

weighted_average([(0,1), (1,1)]) #0.5 
weighted_average([(k, 1) for k in range(2)]) #0.5
weighted_average((k, 1) for k in range(2)) #mistake

但这并不是最好的例子,因为重写为使用单次遍历的weighted_average版本可能更好:

def weighted_average(it):
    '''
    returns weighted average of an iterator it
    it yields values and weights in tuples
    weights don't need to sum up 1 (automatically renormalized)
    '''
    total_value = 0
    total_weight = 0
    for v, w in it:
        total_value += v
        total_weight += w
    return total_value / total_weight

我没有看到通用版本有什么问题,你进行了分析吗?而且你所说的视觉复杂性是什么意思? - LBarret
“在设计算法时,什么情况下不明确第二遍迭代开始的时间?”这到底是什么意思?您可以使用 itertools.tee() 来保证您可以无条件地迭代任意次数。如何在设计算法时不明确这一点呢? - S.Lott
@S.Lott:我的意思只是说,如果算法不是线性的,这可能会在代码的多个位置发生;而且这些位置甚至可能不是完全明显的(例如,如果在违反某些条件时请求新的传递)。一次弄清楚这个问题已经够糟糕了;每次调整算法时都要这样做就更糟糕了。因此,我更喜欢在开始时进行验证。 - max
@max:你的话让人感觉好像算法不是经过设计而是突然出现的。这让人感到不安。你能解释一下为什么普通的设计不起作用吗? - S.Lott
@max:我曾经也为同样的itertools.tee问题苦恼过,试图找到一种方法使其对需要通过可迭代对象进行两次遍历的算法“不可见”。对我来说,这从未是模糊或神秘的,只是需要小心确保迭代器被正确地“tee-d”。我非常好奇它如何通过一个不是“完全明显”的循环变得更加复杂。感谢您考虑了这个问题,而不是像有些人那样坚持认为这是一个要求(当它不是)或者说“他们只是好奇。”好奇心并不能让一个不好的问题变得好。 - S.Lott
显示剩余4条评论
3个回答

6
尽管所有可迭代对象都应该是collections.Iterable的子类,但不幸的是,并非所有对象都是。这里的答案基于对象实现的接口,而不是它们“声明”的内容。
简短回答:
一个“容器”,也就是可以被多次迭代的列表/元组,通常会同时实现__iter__和__getitem__。因此你可以这样做:
>>> def is_container_iterable(o):
...     return hasattr(o, '__iter__') and hasattr(o, '__getitem__')
... 
>>> is_container_iterable([])
True
>>> is_container_iterable(())
True
>>> is_container_iterable({})
True
>>> is_container_iterable(range(5))
True
>>> is_container_iterable(iter([]))
False

长答案:

但是,您可以创建一个不会被耗尽且不支持getitem的可迭代对象。例如,生成素数的函数。如果您想要重复生成多次,则可以重复生成,但是如果要检索第1065个质数,则需要大量计算,因此您可能不希望支持该操作。:-)

那么是否有任何更“可靠”的方法呢?

嗯,所有可迭代对象都将实现一个__iter__函数,该函数将返回一个迭代器。迭代器将具有__next__函数。这是在其上迭代时使用的函数。连续调用__next__最终将耗尽迭代器。

因此,如果它具有__next__函数,则是迭代器,并且将被耗尽。

>>> def foo():
...    for x in range(5):
...        yield x
... 
>>> f = foo()
>>> f.__next__
<method-wrapper '__next__' of generator object at 0xb73c02d4>

尚未成为迭代器的可迭代对象将不具备__next__函数,但会实现__iter__函数,该函数将返回一个可迭代对象:

>>> r = range(5)
>>> r.__next__
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'range' object has no attribute '__next__'
>>> ri = iter(r)
>>> ri.__next__
<method-wrapper '__next__' of range_iterator object at 0xb73bef80>

因此,您可以检查该对象是否具有__iter__,但它没有__next__

>>> def is_container_iterable(o):
...     return hasattr(o, '__iter__') and not hasattr(o, '__next__')
... 
>>> is_container_iterable(())
True
>>> is_container_iterable([])
True
>>> is_container_iterable({})
True
>>> is_container_iterable(range(5))
True
>>> is_container_iterable(iter(range(5)))
False

迭代器还有一个__iter__函数,它将返回自身。

>>> iter(f) is f
True
>>> iter(r) is r
False
>>> iter(ri) is ri
True

因此,您可以进行以下几种检查变体:
>>> def is_container_iterable(o):
...     return iter(o) is not o
... 
>>> is_container_iterable([])
True
>>> is_container_iterable(())
True
>>> is_container_iterable({})
True
>>> is_container_iterable(range(5))
True
>>> is_container_iterable(iter([]))
False

如果您实现了一个返回错误迭代器的对象,即iter()返回的内容不是self,那么它会失败。但这时你(或第三方模块)的代码实际上已经做错了。

虽然进行hasattr调用时不应该产生副作用,但它确实依赖于创建迭代器,因此需要调用对象的__iter__方法,理论上可能会产生副作用。好吧,它会调用getattribute,这可能会有副作用。但您可以通过以下方式解决:

>>> def is_container_iterable(o):
...     try:
...         object.__getattribute__(o, '__iter__')
...     except AttributeError:
...         return False
...     try:
...         object.__getattribute__(o, '__next__')
...     except AttributeError:
...         return True
...     return False
... 
>>> is_container_iterable([])
True
>>> is_container_iterable(())
True
>>> is_container_iterable({})
True
>>> is_container_iterable(range(5))
True
>>> is_container_iterable(iter(range(5)))
False

这个方法相对较安全,适用于所有情况,除非对象在__getattribute__调用时动态生成__next____iter__,但如果你这样做,那么你就是疯了。 :-)

本能地,我更喜欢iter(o) is o的版本,但我从未需要过这样做,所以这不是基于经验的。


+1:如果类没有从collections.Iterable子类化,我没想到会有这样的方法来做这件事。(顺便问一下,一个类可以有意义地从IterableIterator两个类派生吗?) - max
Iterable继承自Iterator,因此不行。 - Lennart Regebro

3
你可以使用 collections 模块中定义的抽象基类来检查并确定 it 是否是 collections.Iterator 的实例。
if isinstance(it, collections.Iterator):
    # handle the iterator case

个人而言,我觉得你的迭代器友好的加权平均值版本要比多个列表推导式/求和版本更易于阅读。 :-)


是的,我同意。我更新了我的问题,展示了一个例子,在这个例子中似乎不可行使用一次遍历。 - max
不错!看起来在3.x版本中有一种标准的方法。 - Ethan Furman
这似乎可以工作,即使是像“虚拟容器”range(5)这样的对象。看起来很棒! - max
1
这对我没有用。如果我运行a =(x for x in [1,2,3]),那么isinstance(a,collections.Iterator)会返回True,即使它应该返回False,因为'a'是一次性迭代的。 - Elias Zamaria

1
最好的方法是使用抽象基类架构:
def weighted_average(c):
    if not isinstance(c, collections.Sequence):
      raise ContainerExpectedException

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