为啥 ''.join() 在 Python 中比 += 快?

Posted

技术标签:

【中文标题】为啥 \'\'.join() 在 Python 中比 += 快?【英文标题】:Why is ''.join() faster than += in Python?为什么 ''.join() 在 Python 中比 += 快? 【发布时间】:2017-01-11 17:35:13 【问题描述】:

我能够在网上找到大量信息(关于 *** 和其他),说明在 Python 中使用 ++= 进行连接是一种非常低效和糟糕的做法。

我似乎找不到为什么+= 效率如此低下。除了提到here“在某些情况下它已针对 20% 的改进进行了优化”(仍然不清楚这些情况是什么)之外,我找不到任何其他信息。

在技术层面上发生了什么使''.join() 优于其他 Python 连接方法?

【问题讨论】:

相关。 ***.com/questions/34008010/… += 对于字符串、整数的行为不同。 Python 可能需要更多时间来确定+= 对其进行操作的数据类型,即如果它们是整数则添加它,而如果它们是字符串则连接。在' '.join() 操作中,它只需要字符串元素——这使得 Python 不必担心它处理的数据类型。 @cricket_007 链接到一个提供更多洞察力的好帖子。不过我已经接受了 mgilson 的回答。 @BryanOakley 检查源代码我什至没有想到。这是解决问题的另一个好方法。 您可能会发现Shlemiel the painter(最初告诉here)的故事有助于了解使用字符串进行大量+= 连接的潜在性能成本。 += 在 Python 中可能是 O(N) 的确切原因与在 C 中 strcatO(N) 的原因并不完全相同,但它是相似的。 【参考方案1】:

假设您有这段代码可以从三个字符串构建一个字符串:

x = 'foo'
x += 'bar'  # 'foobar'
x += 'baz'  # 'foobarbaz'

在这种情况下,Python首先需要分配和创建'foobar',然后才能分配和创建'foobarbaz'

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

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


在某些情况下,还有其他可行的方法可以提高 += 的性能。例如。如果内部字符串表示实际上是 rope,或者运行时实际上足够聪明,可以以某种方式找出临时字符串对程序没有用处,然后将它们优化掉。

但是,CPython 确实可靠地进行这些优化(尽管它可能适用于 few corner cases),并且由于它是最常用的实现,因此许多最佳实践都基于有效的方法很适合 CPython。拥有一套标准化的规范还可以让其他实现更容易集中优化工作。

【讨论】:

当我第一次阅读您的答案时,我想“解释器如何知道它需要为 'foobar' 分配空间?它是否读懂了我的想法,知道我将加入那些在某一点?”我想你假设有像foo += bar + baz 这样的代码。如果您显示会导致分配的代码,您的答案会更有意义。 @BryanOakley:foo += bar; foo += baz; 的行为与这篇文章描述的完全一样。 foo = foo+ bar + baz; 也是。 foo += bar + baz 的工作方式略有不同,但没有更快。 @MooingDuck:我明白了。那不是重点。关键是,原始问题和答案都没有显示表达式foo += bar。初学者可能会偶然发现这个答案,并想知道为什么 python 在没有表达式的情况下为 "foobar" 分配空间。 @Random832 -- 实际上是这样。 join 确实通过输入两次。它通过creating a sequence from the input before the first run-through 完成此操作。 @freakish -- FWIW,有据可查(至少在 *** 上)加入列表推导比加入生成器表达式快。现实世界的时差非常很小,但是我不同意一种或另一种方式是首选的(例如,我从未在风格指南中看到过它)。就个人而言,我通常仍然使用生成器来与我编写的其他代码保持一致,但是在代码审查中,当我不会让sum([x for x in ...]) 之类的东西时,我会让它滑动(为什么要浪费内存?)... 【参考方案2】:

我认为这种行为在Lua's string buffer chapter 中得到了最好的解释。

要在 Python 上下文中重写该解释,让我们从一个无辜的代码 sn-p(Lua 文档中的代码的派生)开始:

s = ""
for l in some_list:
  s += l

假设每个 l 是 20 字节,并且 s 已经被解析为 50 KB 的大小。当 Python 连接 s + l 时,它会创建一个 50,020 字节的新字符串,并将 s 中的 50 KB 复制到这个新字符串中。也就是说,对于每个新行,程序移动 50 KB 的内存,并且还在增长。在读取 100 行新行(仅 2 KB)后,sn-p 已经移动了超过 5 MB 的内存。更糟糕的是,在分配之后

s += l

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

最后:

这个问题不是 Lua 特有的:其他真正垃圾的语言 集合,其中字符串是不可变对象,呈现类似的 行为,Java 是最著名的例子。 (Java 提供 结构 StringBuffer 以改善问题。)

Python 字符串也是immutable objects。

【讨论】:

值得注意的是,CPython(主要的 Python 解释器)在字符串不可变方面作弊(这是模糊地提到的“优化”)。如果它看到您执行+= 并且左侧的名称绑定到一个只有一个引用的字符串,它会尝试调整该字符串的缓冲区大小(这可能会或可能不会工作,具体取决于一些低级内存分配细节)。它在工作时使重复的+= 操作更快(实际上,使用带有+= 的循环可能比"".join 更快)。不使用它的主要原因是为了跨解释器的兼容性。 LuaJIT 早餐吃这种循环,我严重怀疑你会在这里得到超过 2-3 个分配。不过我很想被证明是错误的。 @Blckknght 你能解释一下“跨解释器兼容性”吗? @Blckknght 你怎么看接受的答案,它说So for each += that gets called, the entire contents of the string and whatever is getting added to it need to be copied into an entirely new memory buffer.

以上是关于为啥 ''.join() 在 Python 中比 += 快?的主要内容,如果未能解决你的问题,请参考以下文章

为啥 === 在 JavaScript 中比 == 快? [关闭]

为啥 DISTINCT 在 Pig 中比 GROUP BY/FOREACH 快

为啥将 JSON 对象存储在 cookie 中比字符串更安全或更好?

为啥相同的代码在我的 BackGroundWorker 线程中比在我的 GUI 线程中慢得多?

为啥select count(_) from t,在InnoDB引擎中比MyISAM 慢

python multiprocessing 为啥join之前要close