为什么variable1 += variable2比variable1 = variable1 + variable2快得多?
我接手了一段Python代码,这段代码用来创建非常大的表格(最多有19列,5000行)。在屏幕上显示这个表格花了九秒。我注意到每一行是通过以下代码添加的:
sTable = sTable + '\n' + GetRow()
这里的sTable
是一个字符串。
我把它改成了:
sTable += '\n' + GetRow()
结果我发现表格现在只用了六秒就显示出来了。
然后我又改成了:
sTable += '\n%s' % GetRow()
这个改动是基于这些Python性能优化建议(还是六秒)。
因为这个操作大约调用了5000次,所以这个性能问题就显得很明显了。但为什么会有这么大的差别呢?还有,为什么编译器在第一版中没有发现这个问题并进行优化呢?
1 个回答
这段内容不是在讨论用 +=
还是 +
来添加字符串。你没有告诉我们完整的情况。你最开始的版本是把三个字符串连接在一起,而不仅仅是两个:
sTable = sTable + '\n' + sRow # simplified, sRow is a function call
Python 会尽量优化字符串的连接,无论是用 strobj += otherstrobj
还是 strobj = strobj + otherstringobj
,但如果涉及到超过两个字符串,它就无法进行这种优化。
通常情况下,Python 的字符串是不可变的,但如果左边的字符串对象没有其他引用,并且它正在被重新绑定,那么 Python 会“作弊”,直接修改这个字符串。这可以避免每次连接时都创建一个新字符串,从而提高速度。
这种优化是在字节码评估循环中实现的。无论是使用 BINARY_ADD
来连接两个字符串,还是使用 INPLACE_ADD
来连接两个字符串,Python 都会把连接操作委托给一个特殊的辅助函数 string_concatenate()
。为了能够通过修改字符串来优化连接,Python 首先需要确保这个字符串没有其他引用;如果只有栈和原始变量引用它,那么就可以这样做,并且下一个操作将会替换原始变量的引用。
所以如果字符串只有两个引用,而下一个操作是 STORE_FAST
(设置一个局部变量)、STORE_DEREF
(设置一个被闭包引用的变量)或 STORE_NAME
(设置一个全局变量),并且受影响的变量当前引用的是同一个字符串,那么这个目标变量会被清空,以减少引用数量到只有一个,即栈。
这就是为什么你最初的代码无法完全利用这个优化的原因。你表达式的第一部分是 sTable + '\n'
,而下一个操作又是一个 BINARY_ADD
:
>>> import dis
>>> dis.dis(compile(r"sTable = sTable + '\n' + sRow", '<stdin>', 'exec'))
1 0 LOAD_NAME 0 (sTable)
3 LOAD_CONST 0 ('\n')
6 BINARY_ADD
7 LOAD_NAME 1 (sRow)
10 BINARY_ADD
11 STORE_NAME 0 (sTable)
14 LOAD_CONST 1 (None)
17 RETURN_VALUE
第一个 BINARY_ADD
后面跟着一个 LOAD_NAME
来访问 sRow
变量,而不是一个存储操作。这个第一个 BINARY_ADD
必须总是生成一个新的字符串对象,随着 sTable
的增长,这个新字符串对象会越来越大,创建它所需的时间也会越来越长。
你把这段代码改成了:
sTable += '\n%s' % sRow
这样去掉了第二次连接。现在的字节码是:
>>> dis.dis(compile(r"sTable += '\n%s' % sRow", '<stdin>', 'exec'))
1 0 LOAD_NAME 0 (sTable)
3 LOAD_CONST 0 ('\n%s')
6 LOAD_NAME 1 (sRow)
9 BINARY_MODULO
10 INPLACE_ADD
11 STORE_NAME 0 (sTable)
14 LOAD_CONST 1 (None)
17 RETURN_VALUE
而我们剩下的只是一个 INPLACE_ADD
后面跟着一个存储操作。现在 sTable
可以就地修改,而不再生成一个越来越大的新字符串对象。
你用下面的代码也能得到同样的速度差异:
sTable = sTable + ('\n%s' % sRow)
在这里。
一个时间测试显示了差异:
>>> import random
>>> from timeit import timeit
>>> testlist = [''.join([chr(random.randint(48, 127)) for _ in range(random.randrange(10, 30))]) for _ in range(1000)]
>>> def str_threevalue_concat(lst):
... res = ''
... for elem in lst:
... res = res + '\n' + elem
...
>>> def str_twovalue_concat(lst):
... res = ''
... for elem in lst:
... res = res + ('\n%s' % elem)
...
>>> timeit('f(l)', 'from __main__ import testlist as l, str_threevalue_concat as f', number=10000)
6.196403980255127
>>> timeit('f(l)', 'from __main__ import testlist as l, str_twovalue_concat as f', number=10000)
2.3599119186401367
这个故事的教训是,你根本不应该使用字符串连接。构建一个新字符串的正确方法是先用一个列表,然后使用 str.join()
:
table_rows = []
for something in something_else:
table_rows += ['\n', GetRow()]
sTable = ''.join(table_rows)
这样速度更快:
>>> def str_join_concat(lst):
... res = ''.join(['\n%s' % elem for elem in lst])
...
>>> timeit('f(l)', 'from __main__ import testlist as l, str_join_concat as f', number=10000)
1.7978830337524414
但你不能超过直接用 '\n'.join(lst)
:
>>> timeit('f(l)', 'from __main__ import testlist as l, nl_join_concat as f', number=10000)
0.23735499382019043