在'string=string+s1'和"string+=s1"之间移动语义可以保存多少个复制操作?

How many copy operations can move semantics save between 'string=string+s1' and 'string+=s1'?

本文关键字:多少 保存 操作 复制 s1 string+s1 string string+ 移动 之间 语义      更新时间:2023-10-16

在这个堆栈溢出问题中(不幸的是,自从我开始研究这个问题以来,这个问题一直被搁置),有几个人提到"在现代C++"中",由于移动语义,编译后的代码不需要复制操作string = string + s1的字符串,给人的印象是,使用现代C++编译器,string = string + s1可以和string += s1一样高效。我觉得这种说法很可疑,但我在 C++03 的遗留世界中工作,仍然对移动语义知之甚少。(这是我的工作,我不选择我们的编译器。

string += s1成本

在操作string += s1时,除非string缓冲区的扩展超过其先前分配的容量,否则不需要新的分配,并且假设字符串类的合理实现,则在操作string += s1中不会创建临时对象。假设结果的大小适合先前分配的容量,则string += s1最昂贵的部分是使用先前分配但未使用的空间将s1的内容追加(复制)到string的原始内容的末尾。另请注意,该复制操作的成本只是s1的字节数,而不是总字节数。

旧版C++string = string + s1成本(C++03 及更早版本)

在遗留C++(03 及更早版本)中,string = string + s1,根据我的理解,至少需要一个临时分配(用于评估string + s1),以及两个s1字节数和原始string之和的完整副本(1.string的原始内容复制到临时,并将s1的字节复制到临时这些字节的末尾, 然后2.将所有生成的字节从临时复制回string的缓冲区,包括原始内容的字节,这些字节已经存在)。显然,这比上面描述的string += 1成本要昂贵得多,并且可能会产生显着差异,特别是如果追加操作在循环中多次执行(这是画家的Shlemiel算法,除了它甚至比strcat()的低效率还要糟糕!

"现代"C++的string = string + s1成本(C++11(或C++14?)及以后)

在我看来,表达式string + s1的计算将产生一个右值,随后可以作为右值引用提供给string的移动赋值运算符,从而消除了将string + s1的结果复制回string的需要。但这并不能消除创建原始临时对象及其相关复制的需要,不是吗?在我看来,移动语义所能做的最好的事情就是消除两个完整复制操作中的一个。仍然需要一个分配和一个复制操作(创建临时操作),对吗?如果是这样,那么移动语义只会使代码"不那么糟糕",它并不能阻止它成为画家 Shlemiel 算法的另一个实例,而且它仍然比 C 的 strcat() 更糟糕!

请告诉我我错了。另外:请不要猜测。这是没有帮助的。如果您的回答或评论以"我认为..."开头,请不要提交。

仍然需要一个分配和一个复制操作(创建临时),对吧?如果是这样,那么移动语义只会使代码"不那么糟糕">

据我所知,你是对的。移动语义并没有消除此示例中的所有开销,只有一些开销。

如果string没有足够的容量,则无法避免重新分配和复制所有字符。但是,如果有足够的容量,为了获得最佳性能,无论标准版本如何,您应该做的是:

string.append(s1); // this
string += s1;      // or this

如果容量足够,则不会进行重新分配,也不会复制原始string的字符。与strcat相同。与strcat不同,如果他的能力不足,行为是明确的。


在循环中执行时,理想情况下应在循环之前保留全部容量。如果无法知道结果长度,则存在一个潜在的问题:据我所知,标准没有指定追加的容量增长策略。如果它确实进行几何增长,那么带有附加的平凡循环是好的。如果它只分配完全旧的长度 + 附加长度而没有空间开销,那么简单的追加循环将导致每次迭代中的分配。

我测试了libstdc++,它确实是几何增长。如果你是偏执狂,那么为了保证这一点,你需要在技术上明确检查是否会在每次迭代时发生重新分配,并保留一个几何增长的内存量:

// paranoid mode: enabled
int growth_rate = 2;
for(auto& s1 : range) {
auto new_size = string.size() + s1.size();
auto new_cap = string.capacity();
while (new_cap < new_size)
new_cap *= growth_rate;
string.reserve(new_cap);
string += s1;
}

但是,在这种特殊情况下,std::ostringstream可能更简单。

出于此答案的目的,我忽略了 SSO(小字符串优化),并假设所有字符串大小都大到需要动态内存分配。 这让我们得到了更多的苹果对苹果的比较。

让我们看看string += s1. 执行此操作时,字符串将根据是否有足够的可用空间调整大小,然后将s1的内容复制到string中。 这意味着您最多可以拥有 O(N+M) 字符副本和分配。 这是你能得到的最好的。

现在我们将检查 C++03string = string + s1. 在这里,您将创建一个临时字符串,为缓冲区进行分配,然后如果没有足够的容量,则string另一个分配,以及临时分配的副本。 这意味着在最坏的情况下,您有 2 个分配和 O(2(N+M)) 字符副本。 这远不如第一种情况。

最后我们有 C++11+string = string + s1. 同样,我们必须创建一个临时的,进行分配并将string复制到其中并s1。 与 C++03 不同,我们不必复制回string,因为我们有移动语义。 这意味着在最坏的情况下,您有一个分配,O(N+M)字符副本,以及一些临时字符串成员与string的复制/交换。 这不如第一种情况,但比C++03好多了。

这一切意味着什么? 使用string += s1. 它是string.append(s1)的缩写形式,由于不需要创建临时,因此可提供最佳性能。

std::string s0 = "whatever length";
std::string s1 = "whatever length";
std::string s = s0+s1;

这需要零临时人员。

s = s0+s1;

这需要 1 个临时的,但该临时没有可观察到的效果,因此编译器可以自由地消除它的创建。 这很有挑战性;我不知道在这种情况下有编译器可以将其关闭,但标准明确允许。

s = s + s1;

与前一种情况相同。

s += s1;

如果ss1的长度足够短,则不涉及分配。

std::string short_string = "short"; // so short that 2 copies will fit in SBO
std::string long_string = "some long string that won't fit in SBO";
s = short_string + short_string;

这可以通过零内存分配或副本来完成。

s = s + short_string;

如果s + short_string适合 SBO,则可以使用零内存分配或副本来完成此操作。 这比前一种情况更具挑战性。

s += short_string;

如果总和很短,或者如果它适合缓冲区,这很容易优化分配。


所以这里有一些事情发生。

首先,C++明确允许编译器消除new分配。 因此,如果编译器能够很好地理解这些类型,它可以看到临时new缓冲区仅用作交换空间,并且可以确定在 as-if 规则下它不需要它。

第二件事是省略。 消除是允许的

std::string s = s0+s1;

s0+s1曾经是一个临时对象;现在,它是一个 prvalue 表达式,必须根据 C++17 规则与此处的std::string s对象合并。 那里不再有单独的对象。

在这一点上,PRV值表达式有点像可移植的构造指令。某些操作可以强制它们具体化临时,但使用它们构造匹配类型的值不会具体化临时。

在 c++17 之前,即使在 c++03 中,这里也可以发生 elision,并且s0+s1的返回值可以(并且通常确实)用std::string s省略其恒等式;这就是为什么 prvalue 表达式规则通常被描述为"保证省略"。


现在,其他感兴趣的是:

SomeType s = s0 + s1 + s2 + s3 + s4 + s5;

如果移动SomeType成本低,并且operator+写得正确,这可能涉及零浪费的临时缓冲区。

假设我们有

SomeType operator+( SomeType lhs, SomeType const& rhs ) {
lhs += rhs;
return lhs;
}

而且SomeType(SomeType&&)非常便宜,而且是免费的(就像搬出SomeType~SomeType一样)。

然后

SomeType s = s0 + s1 + s2 + s3 + s4 + s5;

SomeType s = s0;
s += s1;
s += s2;
s += s3;
s += s4;
s += s5;

直至可能生成的汇编代码。

现在

s = s0 + s1 + s2 + s3 + s4 + s5;

不如

s += s0; s += s1; s += s2; s += s3; s += s4; s += s5;

因为第一个不能重用s中已经存在的存储(假设它足够),而第二个可以。


TL;DR有几种不同的s = s + s1;在某些情况下,s += s1更有效率,而在其他情况下则不然。

在几乎所有情况下,标准都允许它同样高效,但编译器不太可能这样做。 在某些情况下,它既被允许具有同等的效率,也可能具有相同的效率。