为什么'.join()在Python中比+ =更快?

Rod*_*lls 63 python optimization

我能够在线找到一大堆信息(在Stack Overflow和其他方面),关于如何在Python中使用++=连接它是一种非常低效和糟糕的做法.

我似乎无法找到为什么+=这么低效.除了这里提到"它在某些情况下已经优化了20%的改进"(仍然不清楚这些情况是什么),我找不到任何其他信息.

在更技术层面上发生了什么,''.join()优于其他Python串联方法?

mgi*_*son 74

假设你有这个代码来构建一个来自三个字符串的字符串:

x = 'foo'
x += 'bar'  # 'foobar'
x += 'baz'  # 'foobarbaz'
Run Code Online (Sandbox Code Playgroud)

在这种情况下,Python首先需要在分配和创建'foobar'之前进行分配和创建'foobarbaz'.

因此,对于每个+=被调用的内容,字符串的全部内容以及添加到其中的任何内容都需要复制到一个全新的内存缓冲区中.换句话说,如果N要连接字符串,则需要分配大约N临时字符串,并将第一个子字符串复制~N次.最后一个子字符串只被复制一次,但平均而言,每个子字符串都会被复制~N/2一次.

有了.join,Python可以发挥一些技巧,因为不需要创建中间字符串.CPython预先计算出需要多少内存,然后分配一个正确大小的缓冲区.最后,它然后将每个片段复制到新缓冲区中,这意味着每个片段只复制一次.


+=在某些情况下,还有其他可行的方法可以带来更好的性能.例如,如果内部字符串表示实际上是一个rope或者运行时实际上足够聪明,以某种方式弄清楚临时字符串对程序没用,并优化它们.

但是,CPython肯定不能可靠地进行这些优化(虽然它可能适用于一些极端情况)并且因为它是最常用的实现,所以许多最佳实践都基于对CPython有效的方法.拥有一套标准化的规范也使其他实现更容易集中他们的优化工作.

  • 当我第一次读到你的答案时,我想"解释器怎么知道它需要为'foobar'分配空间?它是否能让我读到我的想法,知道我会在某个时候加入那些?" 我想你假设有像'foo + = bar + baz`这样的代码.如果你展示了会导致分配的代码,你的答案会更有意义. (6认同)
  • @BryanOakley:`foo + = bar; foo + = baz;`的行为与这篇文章所描述的完全相同.`foo = foo + bar + baz;`也是.`foo + = bar + baz`的工作方式略有不同,但没有更快. (4认同)
  • @ Random832 - 实际上是这样.`join` _does_两次输入输入.它通过[在第一次运行之前从输入创建序列]来实现这一点(https://hg.python.org/cpython/file/tip/Objects/unicodeobject.c#l9862). (4认同)
  • @MooingDuck:我明白。那不是重点。关键是,原始问题和答案均未显示表达式“ foo + = bar”。初学者可能会迷惑于此答案,并且想知道为什么python在缺少表达式的情况下为“ foobar”分配空间。 (2认同)
  • @freakish-FWIW,相当有据可查(至少在Stackoverflow上),加入列表理解比加入生成器表达式快*快*。现实世界中的时差*非常*非常小,因此我不同意采用一种或另一种方式(例如,我从未在样式指南中看到它)。就我个人而言,我通常仍会使用生成器来与我编写的其他代码保持一致,但是在代码审查中,当我不希望使用诸如sum([x for x in ...])之类的东西时,我会让它滑动。 (为什么要浪费内存?)... (2认同)

hjp*_*r92 7

我认为在Lua的字符串缓冲区章节中最好地解释了这种行为.

要在Python的上下文中重写该解释,让我们从一个无辜的代码片段开始(Lua的docs的衍生物):

s = ""
for l in some_list:
  s += l
Run Code Online (Sandbox Code Playgroud)

假设每个l都是20个字节,s并且已经解析为50 KB的大小.当Python连接时,s + l它会创建一个包含50,020字节的新字符串,并将50 KB复制s到此新字符串中.也就是说,对于每个新行,程序移动50 KB的内存,并且不断增长.在阅读了100个新行(仅2 KB)之后,该代码段已经移动了超过5 MB的内存.更糟糕的是,在任务完成后

s += l
Run Code Online (Sandbox Code Playgroud)

旧字符串现在是垃圾.在两个循环周期之后,有两个旧字符串总共产生超过100 KB的垃圾.因此,语言编译器决定运行其垃圾收集器并释放那些100 KB.问题是这将每两个周期发生一次,程序将在读取整个列表之前运行其垃圾收集器两千次.即使完成所有这些工作,其内存使用量也将是列表大小的倍数.

并且,最后:

这个问题并不是Lua特有的:其他具有真正垃圾收集的语言,并且字符串是不可变对象,呈现出类似的行为,Java是最着名的例子.(Java提供了StringBuffer改善问题的结构.)

Python字符串也是不可变对象.

  • 值得注意的是,CPython(主要的Python解释器)在字符串不可变方面有所欺骗(这是"模糊地提到的"优化").如果它看到你做`+ =`并且左侧的名字被绑定到只有一个引用的字符串,它会尝试调整该字符串的缓冲区(根据某些低级别的内存分配,这可能会也可能不会起作用)细节).它使得重复的`+ =`操作在它工作时更快(实际上,使用带有`+ =`的循环可能比`"."".join`更快).不使用它的主要原因是交叉解释器兼容性. (6认同)