这可能是问题的细节,但全面了解问题可以帮助其他遇到此问题的人。
在 mystring += suffix
中的问题是字符串是不可变的,所以实际上等同于 mystring = mystring + suffix
。因此,实现必须创建一个新的字符串对象,将 mystring
中的所有字符复制到它中,然后复制 suffix
中的所有字符。然后,mystring
名称被重新绑定以引用新字符串;原始字符串对象仍然保持不变。
单独看这个问题实际上并不是问题。任何连接这两个字符串的方法都必须这样做,包括 ''.join([mystring, suffix])
;这实际上更糟糕,因为它必须首先构建一个列表对象,然后迭代该列表,并且虽然在将空字符串插入 mystring
和 suffix
之间时没有实际的数据传输,但至少需要一个指令来处理。
+=
变成问题的地方是当你重复使用它时。像这样:
mystring = ''
for c in 'abcdefg' * 1000000:
mystring += c
请记住mystring += c
等价于mystring = mystring + c
。因此,在循环的第一次迭代中,它计算 '' + 'a'
总共复制了1个字符。接下来, 它执行 'a' + 'b'
复制2个字符,然后是 'ab' + 'c'
3个字符, 'abc' + 'd'
4个字符。每个后续的+=
都会重复 所有先前的工作,然后还要复制新的字符串。这会非常浪费。
''.join(...)
更好,因为在那里您等到知道所有字符串之后再复制任何一个,并将每个字符串直接复制到最终字符串对象的正确位置。与一些评论和答案所说的相反,即使必须修改循环以将字符串附加到字符串列表中,然后在循环后使用 join
进行连接,这仍然是正确的。由于列表不是不可变的,因此添加到列表会直接在原地修改它,而且只需要附加单个引用,而不是复制字符串中的所有字符。对列表执行数千次附加比执行数千个字符串+=
操作快得多。
在没有循环的情况下,重复字符串+=
理论上也是一个问题,如果您只是编写类似于以下源代码:
s = 'foo'
s += 'bar'
s += 'baz'
...
但实际上,除非涉及的字符串非常大,否则你很少会手写那么长的代码序列。因此,在循环(或递归函数)中要注意+=
的使用。
你在计时时可能看不到这个结果的原因是,CPython解释器对字符串+=
进行了优化。让我们回到我荒谬的示例循环:
mystring = ''
for c in 'abcdefg' * 1000000:
mystring += c
每次执行
mystring = mystring + c
时,
mystring
的旧值将变成垃圾并被删除,而名称
mystring
最终将指向一个新创建的字符串,该字符串的内容刚好以旧对象的内容开头。我们可以通过识别即将成为垃圾的
mystring
来优化代码,因此我们可以对其进行任何想做的操作而不会有任何人关心。因此,尽管在Python级别上字符串是不可变的,在实现级别上,我们将使它们动态可扩展,并且我们将通过执行正常分配新字符串和复制方法或者扩展目标字符串并仅复制源字符来实现
target += source
,具体取决于是否会使
target
变成垃圾。
这种优化的问题在于很容易被破坏。 它在小而独立的循环中完全有效(顺便说一下,这些是最容易转换为使用 join
的循环)。 但是,如果你正在做更复杂的事情,并且意外地得到了多个字符串引用,那么代码可能会运行得慢得多。
假设您在循环中有一些日志记录调用,并且日志记录系统为了一段时间缓冲其消息以便一次性打印它们(应该是安全的;字符串是不可变的)。 日志系统内对您字符串的引用可能会停止适用 +=
优化。
假设您将循环编写为递归函数(Python本身并不太喜欢,但仍然如此)出于某种原因使用 +=
构建字符串。 外部堆栈帧仍将具有对旧值的引用。
或者,您对字符串执行的操作是生成一系列对象,因此您将它们传递给一个类; 如果类直接在实例中存储字符串,则优化消失,但如果类先进行操作,则优化仍有效。
实际上,看起来像一个非常基本的原始操作的性能要么好要么不好,这取决于使用 +=
的代码之外的其他代码。 在极端情况下,您可以更改完全不同的文件(甚至是第三方包)引入一个巨大的性能回归,在长时间内未更改的模块中!
此外,我理解的是 +=
优化仅在CPython上易于实现,因为它利用了引用计数; 您可以通过查看其引用计数轻松地确定目标字符串何时成为垃圾,而使用更复杂的垃圾收集,则只能在删除引用并等待垃圾收集器运行后才能确定;远远太晚来决定如何实现 +=
。 因此,看起来非常简单的基本代码,应该没有任何可移植性问题,但当您将其移动到另一个实现时,它可能突然运行得太慢而无法使用。
以下是一些基准测试结果以显示问题的规模:
import timeit
def plus_equals(data):
s = ''
for c in data:
s += c
def simple_join(data):
s = ''.join(data)
def append_join(data):
l = []
for c in data:
l.append(c)
s = ''.join(l)
def plus_equals_non_garbage(data):
s = ''
for c in data:
dummy = s
s += c
def plus_equals_maybe_non_garbage(data):
s = ''
for i, c in enumerate(data):
if i % 1000 == 0:
dummy = s
s += c
def plus_equals_enumerate(data):
s = ''
for i, c in enumerate(data):
if i % 1000 == -1:
dummy = s
s += c
data = ['abcdefg'] * 1000000
for f in (
plus_equals,
simple_join,
append_join,
plus_equals_non_garbage,
plus_equals_maybe_non_garbage,
plus_equals_enumerate,
):
print '{:30}{:20.15f}'.format(f.__name__, timeit.timeit(
'm.{0.__name__}(m.data)'.format(f),
setup='import __main__ as m',
number=1
))
在我的系统上,这将打印:
plus_equals 0.066924095153809
simple_join 0.013648986816406
append_join 0.086287975311279
plus_equals_non_garbage 540.663727998733521
plus_equals_maybe_non_garbage 0.731688976287842
plus_equals_enumerate 0.156824111938477
当+=
优化成功时,它的性能非常好(甚至比愚蠢的append_join
稍微快一点)。我的数据表明,在某些情况下,用+=
替换append
+join
可以优化代码,但这种优化的好处不值得冒险,因为未来其他更改可能会导致性能严重下降(如果在循环中有其他实际工作正在进行,那么这种优化效果可能微乎其微;如果没有,则应该使用simple_join
版本)。
通过将plus_equals_maybe_non_garbage
与plus_equals_enumerate
进行比较,即使优化只在每千次+=
操作中失败一次,也会导致性能损失增加5倍。
+=
的优化实际上只是为了拯救那些没有经验的Python程序员或者只是快速懒惰地编写一些草稿代码的人。如果你思考自己在做什么,应该使用join
。
总结:对于固定数量的连接,使用+=
是可以的。但是对于使用循环构建字符串,join
总是更好的选择。实际上,由于+=
的优化,从+=
转换到join
可能不会带来巨大的改进。无论如何,你仍然应该使用join
,因为这种优化并不可靠,当它失败时,差异可能是巨大的。
timeit
模块。 - Martijn Pietersjoin
的列表会更快。 - Blender+=
和多个append
,然后跟随一个单独的''.join()
。 - Jeff Tratner.join
总是胜出的。 - Andy Hayden