关于有害的过度使用 std::move
翻译:2023 11 月 24 日On harmful overuse of std::move
cppreference std::move
论 std::move 的有害过度使用 - The Old New Thing
C++ 的 std::move
函数将其参数转换为右值引用,这使得其内容可以被另一个操作“消费”(移动)。但是,在你为这个新表达能力兴奋不已时,请注意不要过度使用它。
std::string get_name(int id) {std::string name = std::to_string(id);/* 假设这里进行了其他计算 */return std::move(name); // 过度使用 move 的错误示范
}
你可能认为你在通过说“嘿,你看,我在之后不会使用我的局部变量 name
了,所以你可以直接把字符串移动到返回值里”来给编译器一些帮助。不幸的是,你的“帮助”实际上造成了伤害。
添加 std::move
会导致返回语句不能满足复制省略(Copy Elision) (通常称为命名返回值优化,NRVO)的条件:返回的东西必须是与函数返回值类型相同的局部变量的名称。添加的 std::move
阻止了 NRVO,返回值是通过移动构造函数从 name
变量构造的。
std::string get_name(int id) {std::string name = std::to_string(id);/* 假设这里进行了其他计算 */return name; // 正确方式:允许 NRVO
}
这次,我们直接返回 name
,编译器现在可以省略拷贝,直接将 name
变量放入返回值槽中,无需拷贝。(编译器被允许但不强制进行此优化;但在实践中,如果所有代码路径都返回同一个局部变量,所有编译器都会这样做。)
过度热衷使用 std::move
的另一半问题发生在接收端。
extern void report_name(std::string name);void sample1() {std::string name = std::move(get_name()); // 过度使用 move 的错误示范
}void sample2() {report_name(std::move(get_name())); // 过度使用 move 的错误示范
}
在这两个示例函数中,我们获取 get_name()
的返回值,并显式地 std::move
它到一个新的局部变量或函数参数中。这是另一个试图帮忙却最终帮倒忙的例子。
从一个匹配类型的值构造一个值(无论是局部变量还是函数参数)会被省略:匹配的值被直接存储到局部变量或参数中,无需拷贝。但添加 std::move
阻止了此优化发生,该值将通过移动构造。
extern void report_name(std::string name);void sample1() {std::string name = get_name(); // 正确方式:允许初始化省略
}void sample2() {report_name(get_name()); // 正确方式:允许参数初始化省略
}
特别“精彩”的是当你把两个错误结合在一起时。那样的话,你把一个本来完全没有拷贝或移动操作的序列,变成了一个创建了两个额外的临时对象、两次额外的移动操作和两次额外的析构操作的序列。
#include <memory>
struct S {S();S(S const&);S(S &&);~S();
};
extern void consume(S s);// 错误版本
S __declspec(noinline) f1() {S s;return std::move(s); // 错误 1:阻止 NRVO
}void g1() {consume(std::move(f1())); // 错误 2:阻止初始化省略
}
(展示MSVC 为错误版本 f1/g1
和正确版本 f2/g2
生成的汇编代码,清晰地证明了错误版本进行了额外的移动构造和临时对象操作,而正确版本利用 NRVO 和初始化省略实现了零拷贝/移动。)
以下是 msvc 的编译器输出:
; on entry, rcx says where to put the return value在入口处,rcx指出将返回值放在何处。
f1:mov qword ptr [rsp+8], rcxpush rbxsub rsp, 48mov rbx, rcx; construct local variable s on stack在堆栈上构造局部变量slea rcx, qword ptr [rsp+64]call S::S(); copy local variable to return value复制局部变量到返回值lea rdx, qword ptr [rsp+64]mov rcx, rbxcall S::S(S &&); destruct the local variable s析构局部变量 slea rcx, qword ptr [rsp+64]call S::~S(); return the result返回结果mov rax, rbxadd rsp, 48pop rbxretg1:sub rsp, 40; call f1 and store into temporary variable调用f1并存储到临时变量中lea rcx, qword ptr [rsp+56]call f1(); copy temporary to outbound parameter复制临时到出站参数mov rdx, raxlea rcx, qword ptr [rsp+48]call S::S(S &&); call consume with the outbound parameter使用出站参数调用消费mov rcx, raxcall consume(S); clean up the temporary清理临时的lea rcx, qword ptr [rsp+56]call S::~S(); returnadd rsp, 40ret
请注意,调用 g1
会导致总共创建两个额外的 S
副本,一个在 f1
中,另一个用于保存 f1
的返回值。
相比之下,如果我们使用 copy elision:
// Good version
S __declspec(noinline) f2()
{S s;return s;
}void g2()
{consume(f2());
}
那么 msvc 代码生成是
; on entry, rcx says where to put the return value在入口处,rcx指出将返回值放在何处。
f2:push rbxsub rsp, 48mov rbx, rcx; construct directly into return value (still in rcx)直接构造为返回值(仍在rcx中)call S::S(); and return it并将其返回mov rax, rbxadd rsp, 48pop rbxretg2:sub rsp, 40; put return value of f1 directly into outbound parameter将f1的返回值直接放入出站参数中lea rcx, qword ptr [rsp+48]call f2(); call consume with the outbound parameter使用出站参数调用消费mov rcx, eaxcall consume(S); returnadd rsp, 40ret
其他编译器(GCC, Clang, ICC ICX)也有类似结果。在 GCC, Clang 和 ICX 中,你可以启用 -Wpessimizing-move
警告来提示你何时犯了这些错误。
(文章评论区精选翻译)
- 紅樓鍮鍮: 当
std::move
滥用与auto&&
滥用结合时会更糟:auto&& name = get_name();
会不必要地创建一个抑制 NRVO 的引用;auto&& name = std::move(get_name());
实际上会创建一个悬垂引用,因为 C++ 不会延长临时对象的生命周期如果在其和声明的局部引用之间存在函数调用。有趣的是,auto&& name = static_cast<std::string &&>(get_name());
会产生一个有效的引用!我怀疑用static_cast
替换std::move
可能会恢复 NRVO。 - Neil Rashbrook: (回应上条)假设我做对了,如果你移动(
move
)或转换(cast
)了值,你就得不到优化。 - Kevin Norris: (回应上条)有趣的是,似乎 MSVC 能用
return static_cast<T&&>(...)
优化掉移动,但 GCC 和 Clang 不能。虽然 GCC 和 Clang 会对return std::move(...);
发出警告,但它们不会对return static_cast<T&&>(...)
发出警告。 - Solomon Ucko: 我从这里得到的是:
std::move
是一个转换操作(cast)。它应该以与static_cast
完全相同的谨慎态度对待。只在你能清楚地说明这个转换在形式上做了什么(特别是:为什么你期望编译器在该值被转换为右值引用时以不同方式处理它?)、为什么该用例不满足复制省略(包括但不限于 NVRO)的条件、以及被移动源对象(moved-from)之后会怎样(特别是:你应该合理确信被移动源对象永远不会再被使用)时使用它。 - Simon Farnsworth: 注意,与 C++17 及以后的强制 NRVO 不同,Rust 的 NRVO完全是可选的。它在某些情况下甚至是不健全的,因此在某些版本中被禁用。
- Kevin Norris: 有没有什么副作用或其他我看不到的原因,导致
return std::move(name);
的情况不能被优化掉?或者这只是标准遗漏了一个机会,而编译器被标准所约束? - (其他回复 Kevin): 标准要求如果返回值是纯右值(prvalue)则必须省略拷贝,但
std::move(foo)
是一个将亡值(xvalue)。标准允许在 Raymond 描述的 NVRO 情况以及其他一些涉及异常和协程的特殊场景中进行省略。在所有其他情况下,省略只允许在 “as-if” 规则下,这要求编译器证明在可观察行为上没有差异——这通常很困难。广义上将亡值情况下的省略可能无效,因为对象可能在函数调用前就存在,且对其他部分可见或并发访问,跳过其析构会造成问题。标准可以为“误用了std::move()
的 NVRO 情况”开特例,但告诉人们不要那样做更简单。
核心总结:
Raymond Chen 的文章核心警告了在 C++ 中 过度和不必要地使用 std::move
反而会损害性能,特别是在涉及函数返回值和初始化新对象时,因为它会阻止编译器进行关键的优化:
- 在函数返回值上过度使用
std::move
:- 错误做法:
return std::move(local_variable);
- 危害: 阻止了 命名返回值优化 (NRVO)。NRVO 允许编译器直接在函数的返回值槽中构造局部变量,从而完全省略拷贝/移动构造。
- 正确做法: 直接返回局部变量:
return local_variable;
。这满足 NRVO 的条件,编译器(在实践中通常会)进行优化,实现零拷贝/移动。
- 错误做法:
- 在接收函数返回值初始化新对象时过度使用
std::move
:- 错误做法 (初始化变量):
T var = std::move(func());
- 错误做法 (传递参数):
some_func(std::move(func()));
- 危害: 阻止了 初始化省略 (Initialization Elision)。当使用相同类型的值初始化另一个对象(变量或参数)时,编译器可以直接将源值用作目标对象,省略中间的临时对象和拷贝/移动操作。
- 正确做法: 直接使用返回值初始化:
T var = func();
或some_func(func());
。这允许编译器进行初始化省略。
- 错误做法 (初始化变量):
- 双重错误(最糟糕): 如果在返回函数中错误使用
std::move
阻止了 NRVO,并且在调用函数中错误使用std::move
阻止了初始化省略,那么本来可以完全零拷贝/移动的操作链(func()
内部构造 -> 直接用作返回值 -> 直接用作参数或变量),会变成:- 在返回函数中:一次移动构造(因为 NRVO 被阻止)。
- 在调用函数中:创建一个临时对象(存储
func()
的返回值),然后通过移动构造初始化目标变量或参数(因为初始化省略被阻止)。 - 结果:创建了两个额外的临时对象,发生了两次额外的移动操作,并进行了两次额外的析构操作,性能显著下降。
核心教训与建议:
- 优先信任编译器优化: 在简单返回局部变量或直接用返回值初始化相同类型对象时,不要添加
std::move
。让编译器利用 NRVO 和初始化省略规则进行零拷贝优化。 - 将
std::move
视为强制类型转换: 像对待static_cast
一样谨慎使用std::move
。仅在需要显式启用移动语义(例如,要将对象的所有权转移给函数,或你知道源对象不再需要且移动比拷贝更廉价)时使用。 - 理解使用
std::move
的后果: 使用std::move
后,被移动的对象处于有效但未指定状态,不应再依赖其内容。 - 启用编译器警告: 使用 GCC, Clang 或 ICC 时,开启
-Wpessimizing-move
(或等效警告)来检测这种潜在的性能反优化。 - 避免
auto&&
与std::move
的致命组合:auto&& name = std::move(func());
容易创建悬垂引用,因为func()
返回的临时对象生命周期不会因std::move
而延长。
总之: std::move
是一个有用的工具,但滥用它会适得其反,阻碍编译器进行更高效的优化(复制省略),最终导致性能下降和潜在问题。在简单返回局部变量和直接初始化场景中,应首选简洁写法,信任编译器优化。
原文翻译
The C++ std::move
function casts its parameter to an rvalue reference, which enables its contents to be consumed by another operation. But in your excitement about this new expressive capability, take care not to overuse it.
C++
std::move
函数将其参数强制转换为右值引用,从而使其内容可供其他作使用。但是,在您对这种新的表达能力感到兴奋时,请注意不要过度使用它。
std::string get_name(int id)
{std::string name = std::to_string(id);/* assume other calculations happen here 假设这里发生了其他计算*/return std::move(name);
}
You think you are giving the compiler some help by saying “Hey, like, I’m not using my local variable name
after this point, so you can just move the string into the return value.”
您认为您通过说“嘿,比如,在这一点之后我没有使用我的局部变量
名称
,所以你可以将字符串移动到返回值中”来为编译器提供一些帮助。
Unfortunately, your help is actually hurting. Adding a std::move
causes the return
statement to fail to satisfy the conditions for copy elision (commonly known as Named Return Value Optimization, or NRVO): The thing being returned must be the name of a local variable with the same type as the function return value.
不幸的是,你的帮助实际上是有害的。添加
std::move
会导致return
语句无法满足复制省略的条件 (通常称为命名返回值优化,或 NRVO):返回的事物必须是与函数返回值类型相同的局部变量的名称。
The added std::move
prevents NRVO, and the return value is move-constructed from the name
variable.
添加的
std::move
会阻止 NRVO,并且返回值是从name
变量移动构造的。
std::string get_name(int id)
{std::string name = std::to_string(id);/* assume other calculations happen here 假设这里发生了其他计算*/return name;
}
This time, we return name
directly, and the compiler can now elide the copy and put the name
variable directly in the return value slot with no copy. (Compilers are permitted but not required to perform this optimization, but in practice, all compilers will do it if all code paths return the same local variable.)
这一次,我们直接返回
name
,编译器现在可以省略 copy 并将name
变量直接放在没有 copy 的返回值槽中。(允许但不要求编译器执行此优化,但实际上,如果所有代码路径都返回相同的局部变量,则所有编译器都会执行此作。
The other half of the overzealous std::move
is on the receiving end.
过分热心的
std::move
的另一半在接收端。
extern void report_name(std::string name);void sample1()
{std::string name = std::move(get_name());
}void sample2()
{report_name(std::move(get_name()));
}
In these two sample functions, we take the return value from get_name
and explicitly std::move
it into a new local variable or into a function parameter. This is another case of trying to be helpful and ending up hurting.
在这两个示例函数中,我们从
get_name
获取返回值,并显式地 std::move
将其放入新的局部变量或函数参数中。这是另一种试图提供帮助但最终受伤的情况。
Constructing a value (either a local variable or a function parameter) from a matching value of the same type will be elided: The matching value is stored directly into the local variable or parameter without a copy. But adding a std::move
prevents this optimization from occurring, and the value will instead be move-constructed.
从相同类型的匹配值构造值(局部变量或函数参数)将被省略:匹配值直接存储到局部变量或参数中,无需复制。但是添加
std::move
会阻止这种优化的发生,并且该值将被 move 构造。
extern void report_name(std::string name);void sample1()
{std::string name = get_name();
}void sample2()
{report_name(get_name());
}
What’s particularly exciting is when you combine both mistakes. In that case, you took what would have been a sequence that had no copy or move operations at all and converted it into a sequence that creates two extra temporaries, two extra move operations, and two extra destructions.
特别令人兴奋的是,当你把这两个错误结合起来时。在这种情况下,您获取了一个根本没有复制或移动作的序列,并将其转换为一个序列,该序列创建了两个额外的临时作、两个额外的移动作和两个额外的销毁。
#include <memory>
struct S
{S();S(S const&);S(S &&);~S();
};extern void consume(S s); // 消耗// Bad version
S __declspec(noinline) f1()
{S s;return std::move(s);
}void g1()
{consume(std::move(f1()));
}
Here’s the compiler output for msvc:
以下是 msvc 的编译器输出:
; on entry, rcx says where to put the return value在入口处,rcx指出将返回值放在何处。
f1:mov qword ptr [rsp+8], rcxpush rbxsub rsp, 48mov rbx, rcx; construct local variable s on stack在堆栈上构造局部变量slea rcx, qword ptr [rsp+64]call S::S(); copy local variable to return value复制局部变量到返回值lea rdx, qword ptr [rsp+64]mov rcx, rbxcall S::S(S &&); destruct the local variable s析构局部变量 slea rcx, qword ptr [rsp+64]call S::~S(); return the result返回结果mov rax, rbxadd rsp, 48pop rbxretg1:sub rsp, 40; call f1 and store into temporary variable调用f1并存储到临时变量中lea rcx, qword ptr [rsp+56]call f1(); copy temporary to outbound parameter复制临时到出站参数mov rdx, raxlea rcx, qword ptr [rsp+48]call S::S(S &&); call consume with the outbound parameter使用出站参数调用消费mov rcx, raxcall consume(S); clean up the temporary清理临时的lea rcx, qword ptr [rsp+56]call S::~S(); returnadd rsp, 40ret
Notice that calling g1
resulted in the creation of a total of two extra copies of S
, one in f1
and another to hold the return value of f1
.
请注意,调用
g1
会导致总共创建两个额外的S
副本,一个在f1
中,另一个用于保存f1
的返回值。
By comparison, if we use copy elision:
相比之下,如果我们使用 copy elision:
// Good version
S __declspec(noinline) f2()
{S s;return s;
}void g2()
{consume(f2());
}
then the msvc code generation is
那么 msvc 代码生成是
; on entry, rcx says where to put the return value在入口处,rcx指出将返回值放在何处。
f2:push rbxsub rsp, 48mov rbx, rcx; construct directly into return value (still in rcx)直接构造为返回值(仍在rcx中)call S::S(); and return it并将其返回mov rax, rbxadd rsp, 48pop rbxretg2:sub rsp, 40; put return value of f1 directly into outbound parameter将f1的返回值直接放入出站参数中lea rcx, qword ptr [rsp+48]call f2(); call consume with the outbound parameter使用出站参数调用消费mov rcx, eaxcall consume(S); returnadd rsp, 40ret
You get similar results with gcc, clang, and icc icx.
使用 gcc、clang 和 icc icx 可以得到类似的结果。
In gcc, clang, and icx, you can enable the pessimizing-move
warning to tell you when you make these mistakes.
在 gcc、clang 和 icx 中,您可以启用
pessimizing-move
警告,以便在您犯这些错误时通知您。