为什么在Python中列表推导式比map()函数更快?

4
我正在研究Python中类似循环结构的性能问题,并发现以下语句:
除了列表推导式的语法优势外,它们通常与等效的map使用一样快或更快。(性能技巧
与等效的for循环相比,列表推导式运行速度稍快(除非您只是要丢弃结果)。(Python速度
我想知道列表推导式在底层上有什么区别可以带来这种优势。谢谢。

5
@JohnColeman::/ 请这样做并发布答案... - Karoly Horvath
2
@KarolyHorvath 我并不是那个好奇心重的人 -- 但我认为如果你对引擎盖下面的东西感到好奇,抬起它看看也无妨,即使你之后需要请教专业技师来解释你所看到的。 - John Coleman
1
你看过任何相关的问题吗?比如这个 - Jeff Mercado
@JohnColeman,Python/C API是包含详细信息的资源。感谢您指向这个资源。 - Bin
@Bin -- C API包含一些细节,但它并不是源代码本身,例如提供有关Python对象在内存中表示的详细信息。 - John Coleman
显示剩余13条评论
1个回答

12

测试一:抛弃结果。

这是我们的虚拟函数:

def examplefunc(x):
    pass

这里是我们的挑战者:

def listcomp_throwaway():
    [examplefunc(i) for i in range(100)]

def forloop_throwaway():
    for i in range(100):
        examplefunc(i)

我不会对其原始速度进行分析,只回答按照原帖的问题为什么。让我们来看看机器代码的差异。

--- List comprehension
+++ For loop
@@ -1,15 +1,16 @@
- 55           0 BUILD_LIST               0
+ 59           0 SETUP_LOOP              30 (to 33)
               3 LOAD_GLOBAL              0 (range)
               6 LOAD_CONST               1 (100)
               9 CALL_FUNCTION            1
              12 GET_ITER            
-        >>   13 FOR_ITER                18 (to 34)
+        >>   13 FOR_ITER                16 (to 32)
              16 STORE_FAST               0 (i)
-             19 LOAD_GLOBAL              1 (examplefunc)
+
+ 60          19 LOAD_GLOBAL              1 (examplefunc)
              22 LOAD_FAST                0 (i)
              25 CALL_FUNCTION            1
-             28 LIST_APPEND              2
-             31 JUMP_ABSOLUTE           13
-        >>   34 POP_TOP             
-             35 LOAD_CONST               0 (None)
-             38 RETURN_VALUE        
+             28 POP_TOP             
+             29 JUMP_ABSOLUTE           13
+        >>   32 POP_BLOCK           
+        >>   33 LOAD_CONST               0 (None)
+             36 RETURN_VALUE     

竞赛已经开始。Listcomp的第一步是建立一个空列表,而for循环的第一步是设置一个循环。接着它们都会加载全局range()、常量100,并对生成器调用range函数。然后它们都获取当前迭代器并获取下一个项,并将其存储到变量i中。然后它们加载examplefunc和i并调用examplefunc。Listcomp将其附加到列表中并重新开始循环。For loop做了三个指令的同样操作,而不是两个。然后它们都加载None并返回它。
所以在这个分析中,哪个看起来更好?在这里,如果你不关心结果,List comprehension会做一些冗余的操作,如构建列表和附加到它上面。For loop也非常高效。
如果你对它们进行计时,使用for循环比使用list comprehension快大约三分之一。(在这个测试中,examplefunc将其参数除以五丢弃掉了,而不是什么都不做。)
测试二:像平常一样保留结果。
没有虚拟函数的这个测试。所以这是我们的挑战者:
def listcomp_normal():
    l = [i*5 for i in range(100)]


def forloop_normal():
    l = []
    for i in range(100):
        l.append(i*5)

今天对我们来说,差异并没有任何用处。它只是两个块中的两个机器代码。

List comp的机器代码:

 55           0 BUILD_LIST               0
              3 LOAD_GLOBAL              0 (range)
              6 LOAD_CONST               1 (100)
              9 CALL_FUNCTION            1
             12 GET_ITER            
        >>   13 FOR_ITER                16 (to 32)
             16 STORE_FAST               0 (i)
             19 LOAD_FAST                0 (i)
             22 LOAD_CONST               2 (5)
             25 BINARY_MULTIPLY     
             26 LIST_APPEND              2
             29 JUMP_ABSOLUTE           13
        >>   32 STORE_FAST               1 (l)
             35 LOAD_CONST               0 (None)
             38 RETURN_VALUE        

For循环的机器码:

 59           0 BUILD_LIST               0
              3 STORE_FAST               0 (l)

 60           6 SETUP_LOOP              37 (to 46)
              9 LOAD_GLOBAL              0 (range)
             12 LOAD_CONST               1 (100)
             15 CALL_FUNCTION            1
             18 GET_ITER            
        >>   19 FOR_ITER                23 (to 45)
             22 STORE_FAST               1 (i)

 61          25 LOAD_FAST                0 (l)
             28 LOAD_ATTR                1 (append)
             31 LOAD_FAST                1 (i)
             34 LOAD_CONST               2 (5)
             37 BINARY_MULTIPLY     
             38 CALL_FUNCTION            1
             41 POP_TOP             
             42 JUMP_ABSOLUTE           19
        >>   45 POP_BLOCK           
        >>   46 LOAD_CONST               0 (None)
             49 RETURN_VALUE        

很明显,列表推导比for循环指令更少。

列表推导的清单:

  1. 构建一个匿名空列表。
  2. 加载range
  3. 加载100
  4. 调用range
  5. 获取迭代器。
  6. 获取该迭代器上的下一个项目。
  7. 将该项存储到i中。
  8. 加载i
  9. 加载整数五。
  10. 乘以五倍。
  11. 将列表附加到列表。
  12. 重复步骤6-10,直到范围为空。
  13. l指向匿名空列表。

for循环的清单:

  1. 构建一个匿名空列表。
  2. l指向匿名空列表。
  3. 设置循环。
  4. 加载range
  5. 加载100
  6. 调用range
  7. 获取迭代器。
  8. 获取该迭代器上的下一个项目。
  9. 将该项存储到i中。
  10. 加载列表l
  11. 在该列表上加载属性append
  12. 加载i
  13. 加载整数五。
  14. 乘以五倍。
  15. 调用append
  16. 回到顶部。
  17. 转到绝对。

(不包括这些步骤:加载None,返回它。)

列表推导不必做这些事情:

  • 每次加载列表附加,因为它作为本地变量预先绑定。
  • 每个循环加载两次i
  • 花费两个指令回到顶部
  • 直接将项目附加到列表而不是调用附加列表的包装器

总之,如果您要使用这些值,则列表推导会快得多,但如果不使用这些值,则速度会相当慢。

真实速度

测试1:for循环比快大约三分之一*

测试2:列表推导比快大约三分之二*

*大约->第二小数位精确


1
非常感谢您出色的分析! - Bin
@Bin 谢谢!我也帮助了自己,所以双赢! - noɥʇʎԀʎzɐɹƆ

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