一个更好的Python 'for'循环方式

69
我们都知道在Python中执行某个语句多次的常见方法是使用for循环。一般的做法是:
# I am assuming iterated list is redundant.
# Just the number of execution matters.
for _ in range(count):
    pass

我相信没有人会争论上面的代码是常见的实现方式,但还有另一种选择。通过Python列表创建的速度,通过引用相乘来实现。

# Uncommon way.
for _ in [0] * count:
    pass

还有旧的while方法。

i = 0
while i < count:
    i += 1

我测试了这些方法的执行时间。下面是代码。

import timeit

repeat = 10
total = 10

setup = """
count = 100000
"""

test1 = """
for _ in range(count):
    pass
"""

test2 = """
for _ in [0] * count:
    pass
"""

test3 = """
i = 0
while i < count:
    i += 1
"""

print(min(timeit.Timer(test1, setup=setup).repeat(repeat, total)))
print(min(timeit.Timer(test2, setup=setup).repeat(repeat, total)))
print(min(timeit.Timer(test3, setup=setup).repeat(repeat, total)))

# Results
0.02238852552017738
0.011760978361696095
0.06971727824807639

如果差异很小,我不会引入这个话题,但是可以看出速度的差异为100%。如果第二种方法更有效,为什么Python不鼓励这样的用法?是否有更好的方法?

测试是在Windows 10Python 3.6下进行的。

根据@Tim Peters的建议,

.
.
.
test4 = """
for _ in itertools.repeat(None, count):
    pass
"""
print(min(timeit.Timer(test1, setup=setup).repeat(repeat, total)))
print(min(timeit.Timer(test2, setup=setup).repeat(repeat, total)))
print(min(timeit.Timer(test3, setup=setup).repeat(repeat, total)))
print(min(timeit.Timer(test4, setup=setup).repeat(repeat, total)))

# Gives
0.02306803115612352
0.013021619340942758
0.06400113461638746
0.008105080015739174

这提供了一种更好的方式,这基本上回答了我的问题。

为什么这比range更快,因为两者都是生成器。是因为值从不改变吗?


9
再试一次:for _ in itertools.repeat(None, count)。意思是使用Python标准库中的itertools模块来重复迭代None对象count次。循环中的下划线在Python中通常用作占位符来表示不关心的值。因此,这行代码的目的是为了重复执行某些操作count次而已。 - Tim Peters
8
第二种方法的主要问题在于它为整个可丢弃列表分配存储空间。 - Tom Karzes
9
但在实际代码中,循环体会更加复杂,并且占据整体时间的主导地位。如果迭代变量并不重要,那么你只是在白白浪费时间。 - hpaulj
3
@MaxPythone,你能给个例子吗?我有点难以置信 :) (虽然我不是专家 :) ) - Ant
3
谢谢您的回答,但我仍然感到困惑。对于第一个例子,我认为支配执行的是获取/保存/检查您正在尝试获取的视频帧。如果您可以直接从某个帧开始,那么运行空循环没有意义。同样对于另一个例子; 无论您使用什么算法来进行基准测试,在循环中所做的事情将会影响主导,而不是在for循环中如何增加32位计数器。 - Ant
显示剩余7条评论
4个回答

92

使用

for _ in itertools.repeat(None, count)
    do something

“重复”是一种不显眼的方法,可以获得最好的所有世界:需要非常小的空间,每次迭代不会创建新对象。在内部,“重复”的C代码使用本机C整数类型(不是Python整数对象!)来跟踪剩余数量。

因此,计数需要适合平台C ssize_t 类型,这通常在32位盒子上最多为2 ** 31-1 ,在64位盒子上为:

>>> itertools.repeat(None, 2**63)
Traceback (most recent call last):
    ...
OverflowError: Python int too large to convert to C ssize_t

>>> itertools.repeat(None, 2**63-1)
repeat(None, 9223372036854775807)

对我的循环来说已经足够大了 ;-)


再次感谢,如果我想要查找这些实现的源代码(以及其他类似的标准库函数),在哪里可以找到它们? - Max Paython
6
学习曲线相当陡峭!itertools的源代码位于https://github.com/python/cpython/blob/master/Modules/itertoolsmodule.c,而`repeat`的实现跨越了几个不同的函数,靠近`repeat_new`。我是如何知道这个的呢?因为我已经玩Python源代码25年了;-) - Tim Peters
1
@Paddy3118,这真是一个混合包。repeat(None, 100)的含义非常明确:它会返回100次None。虽然循环不关心它提供了哪些值,但这种方式比如使用range(100)更加清晰。但由于这是一种新颖(很少见)的写法,这会影响易读性。尽管如此,第二次看到它时就很明显了,所以并不是什么大问题。总体而言,仅从这个角度来看,对我而言,它只比range(100)稍微不太符合Python风格。Python没有直接的方法来表示“只是做N次循环”,因此没有“真正Pythonic”的方法来实现它。 - Tim Peters
1
另一方面,repeat()并不在底层创建任何新的Python int对象是一个未记录的CPython实现细节,依赖于“速度”就是最不符合Python风格的。 - Tim Peters

11
第一种方法(在Python3中)创建了一个range对象,可以迭代整个值范围。 (它类似于生成器对象,但您可以多次迭代它。)它不会占用太多内存,因为它不包含整个值范围,只包含当前值和最大值,其中它会按步长(默认为1)逐渐增加,直到达到或超过最大值。
比较range(0,1000)list(range(0,1000))的大小:在此在线尝试! 前者非常内存高效;它只占用48字节,无论大小如何,而整个列表按大小线性增加。
第二种方法虽然更快,但占用了我之前所说的那些内存。(另外,尽管 0 占用24个字节, None 占用16个字节,但每个数组都有 10000 相同大小。有趣。可能是因为它们是指针)
有趣的是, [0] * 10000 list(range(10000))小大约10000个字节,这在某种程度上很合理,因为在第一种方法中,所有值都是相同的基本值,所以它可以进行优化。
第三种方法也不错,因为它不需要另一个堆栈值(而调用 range 需要调用堆栈上的另一个空间),尽管由于速度慢6倍,它不值得使用。
最后一种可能是最快的,只因为 itertools 很酷:P我想它使用了一些C库优化,如果我记得正确的话。

range 在 Python 3 中返回一个 range 对象,而不是生成器。证明这一点的一个具体特性是,您可以多次迭代它,而生成器在迭代后被消耗(因此为空)。 - jpmc26

1

这个答案提供了一个循环结构以方便使用。要获取有关使用itertools.repeat进行循环的更多背景,请查阅Tim Peters在上面、Alex Martelli在此处和Raymond Hettinger在此处的回答。

# loop.py

"""
Faster for-looping in CPython for cases where intermediate integers
from `range(x)` are not needed.

Example Usage:
--------------

from loop import loop

for _ in loop(10000):
    do_something()

# or:

results = [calc_value() for _ in loop(10000)]
"""

from itertools import repeat
from functools import partial

loop = partial(repeat, None)

难道让循环成为一个装饰器不是更好吗,这样你就可以这样做: @loop(10000) do_something() - Tweakimp
@Tweakimp 我不确定我是否理解你认为它应该如何工作。当然,您可以装饰一个特定的函数以被调用1000次,但是您将失去调用该函数的灵活性,并且它仅适用于_该_装饰函数。 - Darkonaut
我想调用函数do_something 10000次,并且装饰器可能会将do_something更改为for _ in loop(10000): do_something() - Tweakimp
@Tweakimp 如果你总是想调用它10k次,那么使用装饰器来实现就很有意义了。但这只是一个非常具体的用例,您在这里扩展了“更好”的for-loop的基本思想。 - Darkonaut

0

前两种方法需要为每次迭代分配内存块,而第三种方法只需为每次迭代执行一步操作。

Range是一个较慢的函数,我仅在必须运行不需要速度的小代码时使用它,例如range(0,50)。我认为你不能比较这三种方法;它们完全不同。

根据下面的评论,第一种情况仅适用于Python 2.7,在Python 3中,它的工作方式类似于xrange,并且不会为每次迭代分配内存块。我进行了测试,他是正确的。


6
在 Python 3 中,range 函数生成一个迭代器。与 Python 2 中的 xrange 函数相同。只有第二种方法存在内存问题。 - Tom Karzes
@TomKarzes 仍然不正确(尽管更正确)。它生成一个 range 对象。range 对象不是迭代器或生成器;它可以被多次迭代而不被消耗。 - jpmc26

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