每周技巧 #117:拷贝消除和按值传递
本节阅读量:本文翻译自 Abseil 官网的 Tip of the Week #117: Copy Elision and Pass-by-value。
原文最初作为 TotW #117 发布于 2016 年 6 月 8 日。
作者:Geoff Romer
“一切都如此遥远,一份拷贝的拷贝的拷贝。万物的失眠距离,你碰不到任何东西,也没有任何东西能碰到你。”— Chuck Palahniuk
假设你有一个这样的类:
|
|
你会如何编写它的构造函数?多年来,答案一直是这样:
|
|
不过,现在有一种替代写法越来越常见:
|
|
(如果你不熟悉 std::move(),见 TotW #77,或者假装我用的是 std::swap;同样的原则适用。)这里发生了什么?按拷贝传递 std::string 难道不是非常昂贵吗?事实证明,不,有时按值传递(正如我们将看到的,它其实并不是“按拷贝”)可以比按引用传递高效得多。
为了理解原因,考虑调用点是这样的情况:
|
|
使用第一版 Widget 构造函数时,absl::StrCat() 会生成一个包含拼接字符串值的临时字符串,通过引用传给 Widget(),然后该字符串被复制到 name_ 中。使用第二版 Widget 构造函数时,临时字符串按值传入 Widget()。你可能以为这会导致字符串被复制,但魔法就在这里发生:当编译器看到一个临时对象被用来拷贝构造对象时,编译器会1直接让临时对象和新对象使用同一块存储,因此从一个复制到另一个字面上是免费的;这叫做拷贝消除。由于这个优化,字符串从未被复制,只移动一次,而移动是廉价的常数时间操作。
再考虑实参不是临时对象时会发生什么:
|
|
在这种情况下,两版代码都会复制字符串,但第二版还会移动字符串。同样,移动是廉价的常数时间操作,而复制是线性时间操作,所以在很多情况下,这是很值得付出的代价。
让这种技术可行的 name 参数关键属性是:name 必须被复制。事实上,这种技术的本质,是尝试让拷贝操作发生在函数调用边界上,在那里它可以被消除,而不是发生在函数内部。这并不一定要涉及 std::move();例如,如果函数需要修改副本而不是存储它,也可以直接原地修改。
何时使用拷贝消除
按值传递参数有几个缺点需要记住。首先,它会让函数体更复杂,从而带来维护和可读性负担。例如,在上面的代码中,我们添加了一个 std::move() 调用,这带来意外访问已移动值的风险。在这个函数中风险很小,但如果函数更复杂,风险会更高。
第二,它可能降低性能,有时方式还很意外。不对具体工作负载做性能分析时,有时很难判断它是否净收益。
- 如上所述,这种技术只适用于需要被复制的参数;当应用到不需要复制、或只需要有条件复制的参数上时,最好也没用,最坏则有害。
- 这种技术通常会在函数体中引入某些额外工作,例如上面例子中的移动赋值。如果这些额外工作带来太多开销,那么在无法消除拷贝的情况下,减速可能不值得在可以消除拷贝的情况下获得的加速。注意,这个判断可能取决于你的具体用例。例如,如果传给
Widget()的实参几乎总是很短,或者几乎从不是临时对象,那么这种技术总体上可能有害。和考虑优化权衡时一样,拿不准就测量。 - 当相关拷贝是拷贝赋值时(例如我们想给
Widget添加一个set_name()方法),按引用传递版本有时可以复用name_的现有缓冲区来避免内存分配,而按值传递版本会分配新内存。此外,按值传递总是替换name_分配这一事实,可能导致后续更糟的分配行为:如果name_字段在设置后通常会随时间增长,那么在按值传递情况下,这种增长会需要进一步的新分配;而按引用传递情况下,只有当name_字段超过历史最大大小时才会重新分配。
一般来说,你应该优先选择更简单、更安全、更可读的代码;只有在有具体证据证明复杂版本性能更好且差异重要时,才采用更复杂的写法。这个原则当然适用于这种技术:按 const 引用传递更简单、更安全,因此仍然是很好的默认选择。不过,如果你在已知性能敏感的区域工作,或者基准测试显示你在复制函数参数上花了太多时间,按值传递会是工具箱中非常有用的工具。
-
严格来说,编译器并不必须执行拷贝消除,但这是非常强大且极其重要的优化,你几乎不可能遇到一个不做它的编译器。 ↩︎