为什么[]比list()更快?

796
我在Python 3.11上比较了[]list()的处理速度。
$ python -m timeit '[]'
20000000 loops, best of 5: 11.3 nsec per loop
$ python -m timeit 'list()'
10000000 loops, best of 5: 26.1 nsec per loop

我很惊讶地发现,[]的运行速度大约是list()的两倍。对于{}dict(),我得到了非常相似的结果。
$ python -m timeit '{}'
20000000 loops, best of 5: 11.6 nsec per loop
$ python -m timeit 'dict()'
10000000 loops, best of 5: 27.1 nsec per loop

为什么会这样呢?为什么[]{}(以及可能还有()'')会立即返回一份空的字面量副本,而它们的显式命名对应物(list()dict()tuple()str())则会完全创建一个对象,无论它们是否实际上有元素?

4
注意:()'' 是特殊的,因为它们不仅为空,而且是不可变的,因此将它们作为单例是一个易于实现的优化;它们甚至不会创建新的对象,只是加载用于表示空元组/空字符串的单例。虽然这在技术上是一个实现细节,但我很难想象为什么它们不会出于性能原因缓存空的元组/空字符串。所以你之前关于 []{} 返回存储字面量的设想是错误的,但是对于 ()'' 这一点是正确的。 - ShadowRanger
5个回答

845

因为[]{}是字面量语法,所以Python可以创建字节码来创建列表或字典对象:

>>> import dis
>>> dis.dis(compile('[]', '', 'eval'))
  1           0 BUILD_LIST               0
              3 RETURN_VALUE        
>>> dis.dis(compile('{}', '', 'eval'))
  1           0 BUILD_MAP                0
              3 RETURN_VALUE        

list()dict()是不同的对象。它们的名称需要被解析,堆栈必须参与推送参数,帧必须被存储以便以后检索,并且需要进行调用。这一切都需要更多的时间。

对于空情况而言,至少需要一个LOAD_NAME(需要搜索全局命名空间以及builtins模块),然后是一个CALL_FUNCTION,它必须保留当前帧:

>>> dis.dis(compile('list()', '', 'eval'))
  1           0 LOAD_NAME                0 (list)
              3 CALL_FUNCTION            0
              6 RETURN_VALUE        
>>> dis.dis(compile('dict()', '', 'eval'))
  1           0 LOAD_NAME                0 (dict)
              3 CALL_FUNCTION            0
              6 RETURN_VALUE        

你可以使用 timeit 单独计时名称查找:

>>> import timeit
>>> timeit.timeit('list', number=10**7)
0.30749011039733887
>>> timeit.timeit('dict', number=10**7)
0.4215109348297119

那里的时间差可能是由于字典哈希冲突所致。从调用这些对象的时间中减去这些时间,将结果与使用文字的时间进行比较:

>>> timeit.timeit('[]', number=10**7)
0.30478692054748535
>>> timeit.timeit('{}', number=10**7)
0.31482696533203125
>>> timeit.timeit('list()', number=10**7)
0.9991960525512695
>>> timeit.timeit('dict()', number=10**7)
1.0200958251953125

因此,每调用一次对象会额外增加1.00 - 0.31 - 0.30 == 0.39秒的时间,每1千万次调用增加一次。

您可以通过将全局名称别名为本地名称(使用timeit设置,绑定到名称的所有内容都是本地变量)来避免全局查找成本:

>>> timeit.timeit('_list', '_list = list', number=10**7)
0.1866450309753418
>>> timeit.timeit('_dict', '_dict = dict', number=10**7)
0.19016098976135254
>>> timeit.timeit('_list()', '_list = list', number=10**7)
0.841480016708374
>>> timeit.timeit('_dict()', '_dict = dict', number=10**7)
0.7233691215515137

但你永远无法克服那个CALL_FUNCTION的成本。


listdict需要进行名称查找,因为您可以覆盖它们(例如通过定义自己的名为list的函数)。灵活性是有代价的。 - Zags

168

list()需要进行全局查找和函数调用,但[]编译成单个指令。请参见:

Python 2.7.3
>>> import dis
>>> dis.dis(lambda: list())
  1           0 LOAD_GLOBAL              0 (list)
              3 CALL_FUNCTION            0
              6 RETURN_VALUE        
>>> dis.dis(lambda: [])
  1           0 BUILD_LIST               0
              3 RETURN_VALUE        

78

因为list是将字符串转换为列表对象的函数,而[]用于直接创建列表。试试这个(对你可能更有意义):

x = "wham bam"
a = list(x)
>>> a
["w", "h", "a", "m", ...]

虽然

y = ["wham bam"]
>>> y
["wham bam"]
给你一个包含你输入内容的实际清单。

12
这并没有直接回答问题。问题是关于为什么[]list()更快,而不是为什么['wham bam']list('wham bam')更快。 - Jeremy Visser
3
我不太理解@JeremyVisser的意思,因为[]/list()['wham']/list('wham')是完全相同的,就像数学中的1000/10100/1一样,它们具有相同的变量差异。你可以理论上去掉wham bam,事实仍然是一样的,即list()试图通过调用函数名转换某些内容,而[]会直接转换变量。函数调用是不同的,这只是问题的逻辑概览,例如一个公司的网络地图也是解决问题的逻辑方法。你可以随意投票。 - Torxed
3
相反,它表明他们对内容进行了不同的操作。 - Baldrickk

31
这里的答案很好,简洁明了地回答了这个问题。对于那些感兴趣的人,我会进一步介绍字节码。我正在使用最新的CPython仓库;旧版本在这方面的行为类似,但可能会有轻微的变化。
以下是每个操作的执行过程,BUILD_LIST表示[]CALL_FUNCTION表示list()

BUILD_LIST指令:

你应该只是看一下恐怖的情况:

PyObject *list =  PyList_New(oparg);
if (list == NULL)
    goto error;
while (--oparg >= 0) {
    PyObject *item = POP();
    PyList_SET_ITEM(list, oparg, item);
}
PUSH(list);
DISPATCH();

非常复杂,我知道。这就是它的简单之处:

  • 使用 PyList_New 创建一个新列表(这主要为新列表对象分配内存),oparg 表示堆栈上的参数数量。直截了当。
  • 检查 if (list==NULL) 是否出错。
  • 使用 PyList_SET_ITEM(宏)添加位于堆栈上的任何参数(在我们的情况下不会执行)。

难怪它如此快速!它专门为创建新列表而定制,没有其他用途 :-)

CALL_FUNCTION 指令:

当您查看处理 CALL_FUNCTION 的代码时,首先看到的是:

PyObject **sp, *res;
sp = stack_pointer;
res = call_function(&sp, oparg, NULL);
stack_pointer = sp;
PUSH(res);
if (res == NULL) {
    goto error;
}
DISPATCH();

看起来很无害,对吧?但不幸的是,call_function并不是一个直接调用函数的家伙,它做不到。相反,它从堆栈中获取对象,获取堆栈中的所有参数,然后根据对象的类型进行切换;它是:

我们正在调用list类型,传递给call_function的参数是PyList_Type。CPython现在必须调用一个通用函数来处理任何可调用对象,该函数名为_PyObject_FastCallKeywords,更多函数调用,太棒了。
这个函数再次对某些函数类型进行一些检查(我不明白为什么),然后,在需要时为kwargs创建一个字典,继续调用_PyObject_FastCallDict

_PyObject_FastCallDict 终于可以帮助我们了!在执行了更多的检查之后,它从我们传入的type获取了tp_call插槽, 也就是获取了 type.tp_call。然后,它使用_PyStack_AsTuple将传入的参数创建为元组,并且最终可以进行调用

tp_call是匹配type.__call__的函数,最终创建了列表对象。它调用列表的__new__,对应于PyType_GenericNew,并使用PyType_GenericAlloc为其分配内存:这实际上是它与PyList_New 相遇的部分, 最终。所有前面的都是以通用方式处理对象所必需的。

最后,type_call调用list.__init__并使用任何可用参数初始化列表,然后我们返回原路。:-)

最后,记住LOAD_NAME,还有另一个贡献者在此处。


当Python处理我们的输入时,很容易看出它通常必须跳过一些障碍才能找到适当的C函数来完成工作。它没有立即调用它的礼貌,因为它是动态的,有人可能会掩盖列表(许多人确实这样做),必须采取另一种方法。
这就是list()失去很多东西的地方:Python需要探索以找出它应该做什么。
另一方面,字面语法意味着确切的一件事;它不能被改变,并且始终以预定的方式运行。
注脚:所有函数名称都可能在一个版本中更改。关键点仍然存在,而且在任何未来版本中最有可能保持不变,即动态查找会减慢速度。

我无法用言语来描述我有多喜欢这个解释,但我会尽力。它简洁明了,深入探讨了主题,并有一个优秀的总结来将所有内容联系起来。谢谢! - Jason R Stevens CFA
我正在使用最新的 CPython 存储库,看到人们忘记时间总是向前推进,这总是很有趣的。 - user3064538

22

为什么[]list()快?

最大的原因是Python将list()视为用户自定义函数,这意味着您可以通过别名其他内容到list来截取它并执行不同的操作(例如使用自己的子类列表或deque)。

[]会立即创建一个内置列表的新实例。

我的解释旨在让您产生这种直觉感受。

解释

[]通常称为字面语法。

在语法中,这被称为“列表显示”。根据文档

列表显示是一系列可能为空的表达式,用方括号括起来:

list_display ::=  "[" [starred_list | comprehension] "]"

列表展示将生成一个新的列表对象,其内容由表达式列表或推导式指定。当提供逗号分隔的表达式列表时,其元素从左到右进行评估,并按照该顺序放入列表对象中。当提供推导式时,列表是从推导式生成的元素构建而来。

简而言之,这意味着创建了一个类型为list的内置对象。

无法绕过这一点 - 这意味着Python可以像它想要的那样快速执行它。

另一方面,可以通过使用内置的列表构造函数拦截list()不创建内置的list

例如,假设我们希望以嘈杂的方式创建列表:

class List(list):
    def __init__(self, iterable=None):
        if iterable is None:
            super().__init__()
        else:
            super().__init__(iterable)
        print('List initialized.')

我们可以在模块级别的全局作用域上拦截名称list,然后当我们创建一个list时,实际上创建了我们的子类型列表:

>>> list = List
>>> a_list = list()
List initialized.
>>> type(a_list)
<class '__main__.List'>

同样地,我们可以将它从全局命名空间中移除。

del list

并将其放入内置命名空间中:

import builtins
builtins.list = List

现在:

>>> list_0 = list()
List initialized.
>>> type(list_0)
<class '__main__.List'>

请注意,列表显示会无条件地创建一个列表:

>>> list_1 = []
>>> type(list_1)
<class 'list'>

我们可能只是暂时这样做,所以让我们撤销我们的更改 - 首先从内置函数中删除新的List对象:

>>> del builtins.list
>>> builtins.list
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: module 'builtins' has no attribute 'list'
>>> list()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'list' is not defined

哦,不好,我们迷失了原本的轨迹。

不用担心,我们仍然可以得到list - 它是列表字面值的类型:

>>> builtins.list = type([])
>>> list()
[]

那么为什么[]list()更快呢?

正如我们所看到的 - 我们可以覆盖list - 但是我们无法拦截字面类型的创建。当我们使用list时,我们必须进行查找以查看是否有任何内容。

然后我们必须调用我们查找到的任何可调用对象。从语法中可以看出:

一个调用会用可能为空的一系列参数调用一个可调用对象(例如函数):

call                 ::=  primary "(" [argument_list [","] | comprehension] ")"
我们可以看到它对于任何名称都有相同的效果,而不仅仅是列表:
>>> import dis
>>> dis.dis('list()')
  1           0 LOAD_NAME                0 (list)
              2 CALL_FUNCTION            0
              4 RETURN_VALUE
>>> dis.dis('doesnotexist()')
  1           0 LOAD_NAME                0 (doesnotexist)
              2 CALL_FUNCTION            0
              4 RETURN_VALUE

对于[],在Python字节码层面上不存在函数调用:

>>> dis.dis('[]')
  1           0 BUILD_LIST               0
              2 RETURN_VALUE

它直接构建列表,而不进行任何字节码级别的查找或调用。

结论

我们已经证明,使用作用域规则可以使用用户代码拦截list,并且list()会查找可调用项然后调用它。

[]是列表显示或文字,因此避免了名称查找和函数调用。


4
感谢指出可以劫持list并且Python编译器不能确定它是否真的会返回一个空列表。+1。 - Beefster

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