Python中为什么{}总是比dict快?

dict 类型和 {} 字面表达式有什么区别?为什么{}总是比dict快?

Python 的字典
首先,我们来看看这两种方法在性能上有什么区别。为此,我将使用 timeit 模块。

$ python -m timeit "dict()"
10000000 loops, best of 5: 40 nsec per loop
$ python -m timeit
"{}"
20000000 loops, best of 5: 19.6 nsec per loop

在我的 MacBook M1 上,差异几乎是 2 倍。尤其是在这两个表达式产生的对象完全相同的情况下,差别就更大了。

性能差异从何而来?
在讨论这个问题之前,我们需要偏离一下话题,谈谈执行 Python 代码时会发生什么。

Python 是一种解释型语言--它需要一个解释器。CPython 是使用最广泛的 Python 解释器;它是一个参考实现,这意味着其他解释器使用它来确定 "正确 "的行为。如果您从默认发行版安装了 Python,那么您的机器上就安装了 CPython。

有趣的是,CPython 既是编译器也是解释器。在执行 Python 代码时,它首先将代码编译成字节码指令,然后进行解释。

为了更好地理解 dist() 和 {} 之间的性能差异,我们来比较一下它们编译成的字节码指令。Python 有一个叫做 dis 的内置模块,它的作用正是如此。

>>> import dis
>>> def a():
...   return dict()
...
>>> def b():
...   return {}
...
>>> dis.dis(a)
  1           0 RESUME                   0

  2           2 LOAD_GLOBAL              1 (NULL + dict)
             12 CALL                     0
             20 RETURN_VALUE
>>> dis.dis(b)
  1           0 RESUME                   0

  2           2 BUILD_MAP                0
              4 RETURN_VALUE

虽然它们产生的最终结果相同,但这两种表达方式执行的代码显然不同。

字节码指令
dis.dis 的输出结果并不难理解。

(1) |    (2)    |          (3)          | (4) |      (5)      
-----|-----------|-----------------------|-----|---------------
   1 |         0 | RESUME                | 0   |
     |           |                       |     |
   2 |         2 | LOAD_GLOBAL           | 1   | (NULL + dict)
     |        12 | CALL                  | 0   |
     |        20 | RETURN_VALUE          |     |


每个栏目都有其目的:

  • 源代码中的行号。
  • 指令地址--编译字节码中的字节索引。
  • 字节码名称(操作码)。
  • 操作参数。
  • 操作参数的人可读解释。

好吧,有了这些知识并通过交叉引用操作码集,我们知道:

  1. RESUME- 什么也没做。它执行内部跟踪、调试和优化检查。
  2. LOAD_GLOBAL— 将全局变量加载到堆栈上。从操作码参数的人类可读解释中,我们知道它已加载dict(NULL暂时忽略)。
  3. CALL— 使用由 — 指定的参数数量调用可调用对象,argc在我们的例子中它为零。
  4. RETURN_VALUE— 将堆栈中的最后一个元素返回给调用者。

编译 return {} 会多出一个操作码:

BUILD_MAP - 向堆栈推入一个新的字典对象。推入 2 * 个计数项,使字典中包含计数条目。

在这两种情况下,我们已经发现了一些明显的不同。{} 表达式会直接创建一个字典,而 dict() 会将其委托给一个可调用对象。在此之前,它首先需要将全局值(dict)加载到堆栈中--实际上我们每次调用该函数时它都会这样做。

为什么?

因为 dict 并非恒定不变:它可以被覆盖,从而产生不同的值。

>>> def a():
...   return dict()
...
>>> dict = lambda: 42
>>>
>>> assert a() == 42

这是有可能发生的。这就是为什么 Python 需要在每次调用函数时加载和调用一个可调用的开销。

好吧,听起来很不错。调用可调用对象确实会产生开销,我们可以合理地假设,我们在本篇文章开头看到的差异就是这种开销造成的。然而,我们是否确定 dict() 在内部调用的代码与 {} 调用的代码相同?dict 是一个类,幸运的是,dis.dis 函数可以将类编译为字节码。

import dis

class Foo:
    def bar(self):
        return 42

    def __str__(self):
        return self.__class__.__name__

dis.dis(Foo)

它会打印出所有类方法的反汇编结果:

Disassembly of __str__:
  8           0 RESUME                   0

  9           2 LOAD_FAST                0 (self)
              4 LOAD_ATTR                0 (__class__)
             24 LOAD_ATTR                2 (__name__)
             44 RETURN_VALUE

Disassembly of bar:
  5           0 RESUME                   0

  6           2 RETURN_CONST             1 (42)


试试dict:
>>> dis.dis(dict)
......它没有打印任何东西?为什么?

因为 dis 模块对内部类型没有什么用处,而 dict 就是这些类型中的一种,它是在解释器的源代码中定义的。

{}表达 方式
字面表达应该更容易推理。让我们回到bytecode.c文件并查找 BUILD_MAP操作码的映射。

inst(BUILD_MAP, (values[oparg*2] -- map)) {
    map = _PyDict_FromItems(
            values, 2,
            values+1, 2,
            oparg);
    if (map == NULL)
        goto error;

    DECREF_INPUTS();
    ERROR_IF(map == NULL, error);
}


我们看到字典已完全构造并由_PyDict_FromItems. 让我们去那儿。

PyObject *
_PyDict_FromItems(PyObject *const *keys, Py_ssize_t keys_offset,
                  PyObject *const *values, Py_ssize_t values_offset,
                  Py_ssize_t length)
{
    bool unicode = true;
    PyObject *const *ks = keys;
    PyInterpreterState *interp = _PyInterpreterState_GET();

    for (Py_ssize_t i = 0; i < length; i++) {
        if (!PyUnicode_CheckExact(*ks)) {
            unicode = false;
            break;
        }
        ks += keys_offset;
    }

    PyObject *dict = dict_new_presized(interp, length, unicode);
    if (dict == NULL) {
        return NULL;
    }

    ks = keys;
    PyObject *const *vs = values;

    for (Py_ssize_t i = 0; i < length; i++) {
        PyObject *key = *ks;
        PyObject *value = *vs;
        if (PyDict_SetItem(dict, key, value) < 0) {
            Py_DECREF(dict);
            return NULL;
        }
        ks += keys_offset;
        vs += values_offset;
    }

    return dict;
}

我标记了最有趣的一行:与 相反dict_new,它预先分配字典,因此它已经具有容纳其所有条目的容量。之后它会一一插入键值对。

结论

当您执行 dict(a=1, b=2) 时,Python 需要:

  • 分配一个新的 PyObject、
  • 通过 __new__ 方法构造一个 dict、
  • 调用它的 __init__ 方法,该方法在内部调用 PyDict_Merge、
  • 由于 kwargs 不是 dict,PyDict_Merge 需要使用较慢的方法,即逐个插入条目。

而使用 {} 会导致 Python:
  • 构造一个新的预分配字典、
  • 逐个插入条目。

平心而论,除非你在循环中构造字典,否则我不认为从 dict 到 {} 会带来多少性能上的提升。读完这篇文章后,我希望你记住一件事:

{} 总是比 dict 快!