您的输入过于小,测试只有三个键值对;尽管与列表推导式相比,字典推导式的性能优势不是很大,但对于实际问题规模,它可以并且确实击败了for循环,特别是当针对全局名称时。
如果将输入增加到1000个元素进行测试,则计时非常接近:
>>> import timeit
>>> from random import choice, randint; from string import ascii_lowercase as letters
>>> looped = '''\
... b = {}
... for i,j in a.items():
... b[j]=i
... '''
>>> dictcomp = '''b = {v: k for k, v in a.items()}'''
>>> def rs(): return ''.join([choice(letters) for _ in range(randint(3, 15))])
...
>>> a = {rs(): rs() for _ in range(1000)}
>>> len(a)
1000
>>> count, total = timeit.Timer(looped, 'from __main__ import a').autorange()
>>> (total / count) * 1000000
66.62004760000855
>>> count, total = timeit.Timer(dictcomp, 'from __main__ import a').autorange()
>>> (total / count) * 1000000
64.5464928005822
有差别,字典解析更快,但仅限于这个规模。如果键值对多100倍,则差异会更大:
>>> a = {rs(): rs() for _ in range(100000)}
>>> len(a)
98476
>>> count, total = timeit.Timer(looped, 'from __main__ import a').autorange()
>>> total / count * 1000 # milliseconds, different scale!
15.48140200029593
>>> count, total = timeit.Timer(dictcomp, 'from __main__ import a').autorange()
>>> total / count * 1000 # milliseconds, different scale!
13.674790799996117
考虑到两者都处理了近10万个键值对,这种差异并不是很大。但是,for
循环显然较慢。
那么为什么在只有3个元素时会出现速度差异?因为推导式(字典、集合、列表推导式或生成器表达式)在底层实现为一个新的函数,调用该函数具有基本成本,而普通的循环则无需付出这样的成本。
以下是两种替代方案的字节码反汇编;请注意,在字典推导式的顶级字节码中的MAKE_FUNCTION
和CALL_FUNCTION
操作码,有一个单独的部分来指示该函数执行的操作,事实上,在这两种方法之间几乎没有什么区别:
>>> import dis
>>> dis.dis(looped)
1 0 BUILD_MAP 0
2 STORE_NAME 0 (b)
2 4 SETUP_LOOP 28 (to 34)
6 LOAD_NAME 1 (a)
8 LOAD_METHOD 2 (items)
10 CALL_METHOD 0
12 GET_ITER
>> 14 FOR_ITER 16 (to 32)
16 UNPACK_SEQUENCE 2
18 STORE_NAME 3 (i)
20 STORE_NAME 4 (j)
3 22 LOAD_NAME 3 (i)
24 LOAD_NAME 0 (b)
26 LOAD_NAME 4 (j)
28 STORE_SUBSCR
30 JUMP_ABSOLUTE 14
>> 32 POP_BLOCK
>> 34 LOAD_CONST 0 (None)
36 RETURN_VALUE
>>> dis.dis(dictcomp)
1 0 LOAD_CONST 0 (<code object <dictcomp> at 0x11d6ade40, file "<dis>", line 1>)
2 LOAD_CONST 1 ('<dictcomp>')
4 MAKE_FUNCTION 0
6 LOAD_NAME 0 (a)
8 LOAD_METHOD 1 (items)
10 CALL_METHOD 0
12 GET_ITER
14 CALL_FUNCTION 1
16 STORE_NAME 2 (b)
18 LOAD_CONST 2 (None)
20 RETURN_VALUE
Disassembly of <code object <dictcomp> at 0x11d6ade40, file "<dis>", line 1>:
1 0 BUILD_MAP 0
2 LOAD_FAST 0 (.0)
>> 4 FOR_ITER 14 (to 20)
6 UNPACK_SEQUENCE 2
8 STORE_FAST 1 (k)
10 STORE_FAST 2 (v)
12 LOAD_FAST 1 (k)
14 LOAD_FAST 2 (v)
16 MAP_ADD 2
18 JUMP_ABSOLUTE 4
>> 20 RETURN_VALUE
区别在于:循环代码每次迭代都使用LOAD_NAME
来获取b
,并使用STORE_SUBSCR
将键值对存储在已加载的字典中。字典推导式使用MAP_ADD
实现与STORE_SUBSCR
相同的功能,但不必每次加载b
名称。
但是,仅有3次迭代,字典推导式必须执行的MAKE_FUNCTION
/ CALL_FUNCTION
组合会实际拖慢性能:
>>> make_and_call = '(lambda i: None)(None)'
>>> dis.dis(make_and_call)
1 0 LOAD_CONST 0 (<code object <lambda> at 0x11d6ab270, file "<dis>", line 1>)
2 LOAD_CONST 1 ('<lambda>')
4 MAKE_FUNCTION 0
6 LOAD_CONST 2 (None)
8 CALL_FUNCTION 1
10 RETURN_VALUE
Disassembly of <code object <lambda> at 0x11d6ab270, file "<dis>", line 1>:
1 0 LOAD_CONST 0 (None)
2 RETURN_VALUE
>>> count, total = timeit.Timer(make_and_call).autorange()
>>> total / count * 1000000
0.12945385499915574
使用一个参数创建函数对象并调用它需要超过0.1微秒的时间(还有额外的LOAD_CONST
为传入的None
值)!这只是循环和推导式之间处理3个键值对所需的时间差异。
你可以将其类比为惊讶于用铲子挖一个小洞比用挖掘机快。挖掘机当然能够快速挖掘,但如果需要先让挖掘机启动并移动到适当位置,用铲子开始会更快!
在几个键值对之后(挖掘更大的洞),创建和调用函数的成本就消失了。此时,字典推导式和显式循环基本上执行相同的操作:
- 获取下一个键值对,并将其推送到堆栈中
- 通过字节码操作调用
dict.__setitem__
钩子,并使用堆栈上的前两个项(使用STORE_SUBSCR
或MAP_ADD
)。这不算作“函数调用”,因为所有操作都在解释器循环内部处理。
这与列表推导式不同,其中普通循环版本必须使用list.append()
,每次循环迭代都涉及属性查找和函数调用。列表推导式的速度优势来自于这种差异;参见Python list comprehension expensive
字典推导式的一个附加好处是,当将b绑定到最终字典对象时,仅需查找一次目标字典名称。如果目标字典是全局变量而不是本地变量,则推导式胜出:
>>> a = {rs(): rs() for _ in range(1000)}
>>> len(a)
1000
>>> namespace = {}
>>> count, total = timeit.Timer(looped, 'from __main__ import a; global b', globals=namespace).autorange()
>>> (total / count) * 1000000
76.72348440100905
>>> count, total = timeit.Timer(dictcomp, 'from __main__ import a; global b', globals=namespace).autorange()
>>> (total / count) * 1000000
64.72114819916897
>>> len(namespace['b'])
1000
所以只需使用字典推导式。在处理少于30个元素的情况下,区别太小并且一旦生成全局或有更多项,字典推导式就会胜出。