当前位置: 首页 > news >正文

关于有害的过度使用 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 反而会损害性能,特别是在涉及函数返回值和初始化新对象时,因为它会阻止编译器进行关键的优化:

  1. 在函数返回值上过度使用 std::move
    • 错误做法: return std::move(local_variable);
    • 危害: 阻止了 命名返回值优化 (NRVO)。NRVO 允许编译器直接在函数的返回值槽中构造局部变量,从而完全省略拷贝/移动构造
    • 正确做法: 直接返回局部变量:return local_variable;。这满足 NRVO 的条件,编译器(在实践中通常会)进行优化,实现零拷贝/移动。
  2. 在接收函数返回值初始化新对象时过度使用 std::move:
    • 错误做法 (初始化变量): T var = std::move(func());
    • 错误做法 (传递参数): some_func(std::move(func()));
    • 危害: 阻止了 初始化省略 (Initialization Elision)。当使用相同类型的值初始化另一个对象(变量或参数)时,编译器可以直接将源值用作目标对象,省略中间的临时对象和拷贝/移动操作
    • 正确做法: 直接使用返回值初始化:T var = func();some_func(func());。这允许编译器进行初始化省略。
  3. 双重错误(最糟糕): 如果在返回函数中错误使用 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 警告,以便在您犯这些错误时通知您。

http://www.xdnf.cn/news/987139.html

相关文章:

  • Delphi 获取 XP系统 mac地址
  • Selenium工作原理
  • 【leetcode】36. 有效的数独
  • 利用递归来遍历树
  • Android学习之Window窗口
  • 一个数组样式上要分成两个
  • Unity UGUI GraphicRaycaster.Raycast详解
  • 免费开源的微信开发框架
  • LangSmith 实战指南:大模型链路调试与监控的深度解析
  • Linux 内核 Slab 分配器核心组件详解
  • 【Linux】Linux高级I/O
  • 循环中的break和continue
  • Redis免费客户端工具推荐
  • Altair:用Python玩转声明式可视化(新手友好向)
  • C#委托代码记录
  • 推荐系统入门最佳实践:Slope One 算法详解与完整实现
  • 记录下blog的成长过程
  • 我的世界进阶模组开发教程——制作机械动力附属模组
  • MySQL存储引擎--深度解析
  • Go 语言 JWT 深度集成指南
  • 什么是哈希函数
  • C语言——深入解析字符串函数与其模拟实现
  • const auto 和 auto
  • Bash 脚本中的特殊变量
  • python使用SQLAlchemy 库操作本地的mysql数据库
  • python基本语法元素
  • python-docx 库教程
  • Oracle中10个索引优化
  • 美团NoCode中的Dev Mode 使用指南
  • 在windows中安装或卸载nginx