Python中为什么{}总是比dict快?
dict 类型和 {} 字面表达式有什么区别?为什么{}总是比dict快?
Python 的字典
首先,我们来看看这两种方法在性能上有什么区别。为此,我将使用 timeit 模块。
$ python -m timeit "dict()" |
在我的 MacBook M1 上,差异几乎是 2 倍。尤其是在这两个表达式产生的对象完全相同的情况下,差别就更大了。
性能差异从何而来?
在讨论这个问题之前,我们需要偏离一下话题,谈谈执行 Python 代码时会发生什么。
Python 是一种解释型语言--它需要一个解释器。CPython 是使用最广泛的 Python 解释器;它是一个参考实现,这意味着其他解释器使用它来确定 "正确 "的行为。如果您从默认发行版安装了 Python,那么您的机器上就安装了 CPython。
有趣的是,CPython 既是编译器也是解释器。在执行 Python 代码时,它首先将代码编译成字节码指令,然后进行解释。
为了更好地理解 dist() 和 {} 之间的性能差异,我们来比较一下它们编译成的字节码指令。Python 有一个叫做 dis 的内置模块,它的作用正是如此。
>>> import dis |
虽然它们产生的最终结果相同,但这两种表达方式执行的代码显然不同。
字节码指令
dis.dis 的输出结果并不难理解。
(1) | (2) | (3) | (4) | (5) |
每个栏目都有其目的:
- 源代码中的行号。
- 指令地址--编译字节码中的字节索引。
- 字节码名称(操作码)。
- 操作参数。
- 操作参数的人可读解释。
好吧,有了这些知识并通过交叉引用操作码集,我们知道:
- RESUME- 什么也没做。它执行内部跟踪、调试和优化检查。
- LOAD_GLOBAL— 将全局变量加载到堆栈上。从操作码参数的人类可读解释中,我们知道它已加载dict(NULL暂时忽略)。
- CALL— 使用由 — 指定的参数数量调用可调用对象,argc在我们的例子中它为零。
- RETURN_VALUE— 将堆栈中的最后一个元素返回给调用者。
编译 return {} 会多出一个操作码:
BUILD_MAP - 向堆栈推入一个新的字典对象。推入 2 * 个计数项,使字典中包含计数条目。
在这两种情况下,我们已经发现了一些明显的不同。{} 表达式会直接创建一个字典,而 dict() 会将其委托给一个可调用对象。在此之前,它首先需要将全局值(dict)加载到堆栈中--实际上我们每次调用该函数时它都会这样做。
为什么?
因为 dict 并非恒定不变:它可以被覆盖,从而产生不同的值。
>>> def a(): |
这是有可能发生的。这就是为什么 Python 需要在每次调用函数时加载和调用一个可调用的开销。
好吧,听起来很不错。调用可调用对象确实会产生开销,我们可以合理地假设,我们在本篇文章开头看到的差异就是这种开销造成的。然而,我们是否确定 dict() 在内部调用的代码与 {} 调用的代码相同?dict 是一个类,幸运的是,dis.dis 函数可以将类编译为字节码。
import dis |
它会打印出所有类方法的反汇编结果:
Disassembly of __str__: |
试试dict:
>>> dis.dis(dict)
......它没有打印任何东西?为什么?
因为 dis 模块对内部类型没有什么用处,而 dict 就是这些类型中的一种,它是在解释器的源代码中定义的。
{}表达 方式
字面表达应该更容易推理。让我们回到bytecode.c文件并查找 BUILD_MAP操作码的映射。
inst(BUILD_MAP, (values[oparg*2] -- map)) { |
我们看到字典已完全构造并由_PyDict_FromItems. 让我们去那儿。
PyObject * |
我标记了最有趣的一行:与 相反dict_new,它预先分配字典,因此它已经具有容纳其所有条目的容量。之后它会一一插入键值对。
结论
当您执行 dict(a=1, b=2) 时,Python 需要:
- 分配一个新的 PyObject、
- 通过 __new__ 方法构造一个 dict、
- 调用它的 __init__ 方法,该方法在内部调用 PyDict_Merge、
- 由于 kwargs 不是 dict,PyDict_Merge 需要使用较慢的方法,即逐个插入条目。
而使用 {} 会导致 Python:
- 构造一个新的预分配字典、
- 逐个插入条目。
平心而论,除非你在循环中构造字典,否则我不认为从 dict 到 {} 会带来多少性能上的提升。读完这篇文章后,我希望你记住一件事:
{} 总是比 dict 快!