(这可能是 OP 已经知道的很多细节,但是完整地解决这个问题可以帮助其他最终解决这个问题的人)
问题mystring += suffix
在于字符串是不可变的,所以这实际上等价于mystring = mystring + suffix
. 所以实现必须创建一个新的字符串对象,将所有字符从mystring
上面复制到它,然后从suffix
之后复制所有字符。然后mystring
名称被反弹来引用新的字符串;被引用的原始字符串对象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
会被做成垃圾。
这种优化的问题在于它很容易被破坏。它在小型自包含循环上工作得非常好(顺便说一句,这是最容易转换为 usingjoin
的)。但是,如果您正在做一些更复杂的事情,并且您不小心最终得到了多个对字符串的引用,那么代码会突然运行得慢很多。
假设您在循环中有一些日志记录调用,并且日志记录系统将其消息缓冲一段时间以便一次打印它们(应该是安全的;字符串是不可变的)。在日志系统中对您的字符串的引用可能会阻止+=
优化的适用。
假设您已将循环编写为递归函数(Python 并不真正喜欢它,但仍然)+=
出于某种原因构建了一个字符串。外部堆栈帧仍将引用旧值。
或者,您对字符串所做的事情可能是生成一系列对象,因此您将它们传递给一个类;如果类直接将字符串存储在实例中,优化就会消失,但如果类首先操作它们,那么优化仍然有效。
本质上,看起来像一个非常基本的原始操作的性能要么可以,要么很差,它取决于其他代码,而不是使用+=
. 在极端情况下,您可能会对一个完全独立的文件(甚至可能是第三方包)进行更改,这会在您的一个模块中引入大量的性能退化,而该模块已经很长时间没有更改了!
另外,我的理解是+=
优化只在 CPython 上很容易实现,因为它利用了引用计数;您可以通过查看目标字符串的引用计数轻松判断目标字符串何时为垃圾,而对于更复杂的垃圾收集,您只能在删除引用并等待垃圾收集器运行后才能判断;为时已晚,无法决定如何实施+=
。再说一次,真正简单的基本代码在 Python 实现之间移植时不应该有任何问题,但当您将其移动到另一个实现时,它可能会突然运行得太慢而无法使用。
这里有一些基准测试来显示问题的规模:
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