在返回语句中是否需要使用std::move,并且是否应该返回右值引用?

519
我正在努力理解C++11中的rvalue引用和移动语义。
这些示例有什么区别,哪个示例不会进行向量复制?
第一个示例
std::vector<int> return_vector(void)
{
    std::vector<int> tmp {1,2,3,4,5};
    return tmp;
}

std::vector<int> &&rval_ref = return_vector();

第二个例子
std::vector<int>&& return_vector(void)
{
    std::vector<int> tmp {1,2,3,4,5};
    return std::move(tmp);
}

std::vector<int> &&rval_ref = return_vector();

第三个例子

std::vector<int> return_vector(void)
{
    std::vector<int> tmp {1,2,3,4,5};
    return std::move(tmp);
}

std::vector<int> &&rval_ref = return_vector();

61
请勿返回本地变量的引用,即使是右值引用也不行,因为右值引用仍然是引用。 - fredoverflow
82
那显然是有意为之,为了理解例子之间的语义差异。哈哈 - Tarantula
8
std::move(expression) 并不会创建新的对象,它只是将表达式转换为一个 xvalue。在计算 std::move(expression) 的过程中没有任何对象被复制或移动。 - fredoverflow
1
@TylerH如果你有更好的标题,请提供一个,而不是简单地回滚到“C++:某种混乱”。这样的标题不清楚,使用了标题标签,因此违反了SO的建议。回滚不应该毫无思考地回滚改进,相反,编辑应该始终提高帖子的质量,并理想情况下解决所有问题,而不是留下一些未解决的问题,更不用说引入旧问题了。 - undefined
作为问题的主要作者,我必须诚实地说,这两个标题都没有捕捉到问题的原始意思,我不知道为什么会有这么多纷争,很明显,问题并不是关于返回 rval 引用的意图,我在2011年已经明确说明了,所以似乎12年的时间还不足够让人们理解。我们能不能请停止每个月都改变这个问题12年了呢? - undefined
显示剩余6条评论
6个回答

659

第一个例子

std::vector<int> return_vector(void)
{
    std::vector<int> tmp {1,2,3,4,5};
    return tmp;
}

std::vector<int> &&rval_ref = return_vector();

第一个示例返回一个临时变量,被 rval_ref 捕获。这个临时变量的生命周期将延长超出 rval_ref 的定义,并且您可以像按值捕获一样使用它。这与以下内容非常相似:

const std::vector<int>& rval_ref = return_vector();

除了在我的重写中,你显然不能以非const方式使用rval_ref

第二个例子

std::vector<int>&& return_vector(void)
{
    std::vector<int> tmp {1,2,3,4,5};
    return std::move(tmp);
}

std::vector<int> &&rval_ref = return_vector();

在第二个示例中,您创建了一个运行时错误。 rval_ref 现在持有函数内已销毁的 tmp 的引用。希望能幸运地让这段代码立即崩溃。

第三个示例

std::vector<int> return_vector(void)
{
    std::vector<int> tmp {1,2,3,4,5};
    return std::move(tmp);
}

std::vector<int> &&rval_ref = return_vector();

你的第三个示例与第一个示例大致相同。在tmp上使用std::move是不必要的,实际上可能会成为性能的劣化,因为它会抑制返回值优化。

编写你正在做的最佳方法是:

最佳实践

std::vector<int> return_vector(void)
{
    std::vector<int> tmp {1,2,3,4,5};
    return tmp;
}

std::vector<int> rval_ref = return_vector();

就像在C++03中一样。 tmp在返回语句中隐式地被视为右值。它将通过返回值优化(无复制,无移动)返回,或者如果编译器决定无法执行RVO,则将使用vector的移动构造函数进行返回。仅当未执行RVO,并且返回类型没有移动构造函数时,才会使用复制构造函数进行返回。


78
当您通过值返回本地对象且本地对象的类型与函数的返回类型相同且两者都未被cv限定符修饰时,编译器将执行返回值优化(RVO)。请勿返回const类型,并避免使用条件语句(:?)以免抑制RVO。不要将本地对象包装在其他返回引用到本地对象的函数中。只需使用return my_local;即可。多个return语句是可以的,不会抑制RVO。 - Howard Hinnant
39
有一个注意事项:当返回本地对象的_member_时,移动操作必须是显式的。 - boycy
5
在返回行上没有创建临时变量。move不会创建临时变量。它将lvalue转换为xvalue,不进行复制,不创建任何东西,也不销毁任何东西。这个例子与如果通过左值引用返回并从返回行中删除move完全相同:无论哪种方式,您都会得到一个对函数内部的局部变量的悬挂引用,该变量已经被销毁。 - Howard Hinnant
19
多个返回语句是可以的,并且不会影响返回值优化(RVO),但前提是它们必须返回相同的变量。 - Deduplicator
5
@Deduplicator: 您是正确的。我的表述不够精确。我想说的是多个返回语句不会禁止编译器进行RVO(尽管这使得实现变得不可能),因此返回表达式仍被视为rvalue。 - Howard Hinnant
显示剩余19条评论

48

它们中没有一个会复制,但第二个将引用一个已销毁的向量。在常规代码中几乎从不使用命名的右值引用。您只需按照C++03中的复制方式编写即可。

std::vector<int> return_vector()
{
    std::vector<int> tmp {1,2,3,4,5};
    return tmp;
}

std::vector<int> rval_ref = return_vector();

除了现在,这个向量已经移动了。在绝大多数情况下,一个类的用户不会处理它的右值引用。


你确定第三个例子会执行向量复制吗? - Tarantula
@Tarantula:它将会破坏你的向量。它在崩溃之前是否复制了它并不重要。 - Puppy
4
我不认为你提议的“破坏”有任何理由。将一个本地的右值引用变量绑定到一个右值是完全可以的。在这种情况下,临时对象的生命周期被延长到右值引用变量的生命周期。 - fredoverflow
2
只是澄清一下,因为我正在学习这个。在这个新的例子中,向量tmp并没有被移动rval_ref中,而是直接使用RVO(即复制省略)直接写入rval_ref中。std::move和复制省略之间有区别。一个std::move可能仍然涉及一些数据需要被复制;对于一个向量来说,在复制构造函数中实际上会构造一个新的向量并分配数据,但大部分数据数组只是通过复制指针(本质上)进行复制。复制省略避免了100%的所有复制。 - Mark Lakata
@MarkLakata 这是 NRVO,而不是 RVO。NRVO 是可选的,即使在 C++17 中也是如此。如果没有应用它,则返回值和 rval_ref 变量都使用 std::vector 的移动构造函数进行构造。无论是否使用 std::move,都不涉及复制构造函数。在这种情况下,tmpreturn 语句中被视为 _rvalue_。 - Daniel Langr
@DanielLangr 是正确的。在这种情况下,因为返回值被命名为 tmp,所以可能会应用 NRVO(或者不会,因为它是可选的)。如果 return_vector 只是 {return std::vector{1,2,3,4,5};,那么它就是 RVO。我的观点是,使用一个可以执行 RVO 和 NRVO 的良好编译器,rval_ref 不会被复制构造或移动构造 - 它将直接构造为 std::vector<int>{1,2,3,4,5} - Mark Lakata

18
简单地说,您应该像编写常规引用代码一样编写 rvalue 引用代码,并且在 99% 的情况下应该将它们视为相同。这包括有关返回引用的所有旧规则(即永远不要返回对局部变量的引用)。
除非您正在编写一个需要利用 std::forward 并能够编写接受左值或右值引用的通用函数的模板容器类,否则这是更为真实的。
移动构造函数和移动赋值的一个巨大优势在于,如果你定义了它们,编译器可以在 RVO(返回值优化)和 NRVO(命名返回值优化)无法调用时使用它们。这对于从方法有效地按值返回昂贵的对象,如容器和字符串,来说非常重要。
现在,使用 rvalue 引用作为普通函数的参数也很有趣。这使您可以编写具有 const 引用(const foo &other)和 rvalue 引用(foo && other)的重载容器。即使参数过于复杂以至于不能通过纯构造函数调用传递,仍然可以这样做:
std::vector vec;
for(int x=0; x<10; ++x)
{
    // automatically uses rvalue reference constructor if available
    // because MyCheapType is an unamed temporary variable
    vec.push_back(MyCheapType(0.f));
}


std::vector vec;
for(int x=0; x<10; ++x)
{
    MyExpensiveType temp(1.0, 3.0);
    temp.initSomeOtherFields(malloc(5000));

    // old way, passed via const reference, expensive copy
    vec.push_back(temp);

    // new way, passed via rvalue reference, cheap move
    // just don't use temp again,  not difficult in a loop like this though . . .
    vec.push_back(std::move(temp));
}

STL容器已经更新,几乎任何地方都有移动构造函数(哈希键和值、向量插入等),这也是你最常见到它们的地方。
你也可以将其用于普通函数,如果你只提供一个右值引用参数,你可以强制调用者创建对象并让函数执行移动操作。这更多是一个例子而不是真正的好用处,但在我的渲染库中,我为所有加载的资源分配了一个字符串,以便在调试器中更容易看到每个对象所代表的内容。接口类似于:
TextureHandle CreateTexture(int width, int height, ETextureFormat fmt, string&& friendlyName)
{
    std::unique_ptr<TextureObject> tex = D3DCreateTexture(width, height, fmt);
    tex->friendlyName = std::move(friendlyName);
    return tex;
}

这是一种“漏洞抽象”的形式,但大多数情况下允许我利用已经创建的字符串,避免再次复制它。虽然这不是高性能代码,但是它展示了这个特性的潜力。实际上,这段代码要求变量要么是调用的临时变量,要么被std::move调用:

// move from temporary
TextureHandle htex = CreateTexture(128, 128, A8R8G8B8, string("Checkerboard"));

或者

// explicit move (not going to use the variable 'str' after the create call)
string str("Checkerboard");
TextureHandle htex = CreateTexture(128, 128, A8R8G8B8, std::move(str));

或者

// explicitly make a copy and pass the temporary of the copy down
// since we need to use str again for some reason
string str("Checkerboard");
TextureHandle htex = CreateTexture(128, 128, A8R8G8B8, string(str));

但是这段代码无法编译!

string str("Checkerboard");
TextureHandle htex = CreateTexture(128, 128, A8R8G8B8, str);

4
并非一个答案,但是是一条指南。大多数情况下,在声明局部&&变量时(例如你在std:vector && rval_ref中这样做),很少有意义。您仍然需要使用std::move()将它们移动到foo(T &&)类型的方法中才能使用。还有一个问题,就是当您尝试从函数返回rval_ref时,您将会遇到标准引用销毁临时故障的问题。
大多数情况下,我会采用以下模式:
// Declarations
A a(B&&, C&&);
B b();
C c();

auto ret = a(b(), c());

您不持有对返回的临时对象的引用,因此避免了(经验不足的)程序员希望使用已移动对象的错误。

auto bRet = b();
auto cRet = c();
auto aRet = a(std::move(b), std::move(c));

// Either these just fail (assert/exception), or you won't get 
// your expected results due to their clean state.
bRet.foo();
cRet.bar();

显然,有时候一个函数确实会返回一个T&&的引用,这是一个指向你可以移动到你的对象中的非临时对象。

关于RVO:这些机制通常有效,编译器可以很好地避免复制,但在返回路径不明显的情况下(异常、if条件确定你将返回的命名对象,可能还有其他情况),rrefs是你的救星(即使可能更昂贵)。


3

以上任何一种方法都不会发生任何额外的复制。即使未使用RVO,新标准也规定在返回值时移动构造优于复制构造。

我认为您的第二个示例会导致未定义行为,因为您正在返回对局部变量的引用。


2

如已在第一个答案的评论中提到,return std::move(...);构造在除了返回局部变量之外的情况下可能会有所不同。以下是一个可运行的示例,记录了使用和不使用std::move()返回成员对象时所发生的情况:

Original Answer翻译成"最初的回答"

#include <iostream>
#include <utility>

struct A {
  A() = default;
  A(const A&) { std::cout << "A copied\n"; }
  A(A&&) { std::cout << "A moved\n"; }
};

class B {
  A a;
 public:
  operator A() const & { std::cout << "B C-value: "; return a; }
  operator A() & { std::cout << "B L-value: "; return a; }
  operator A() && { std::cout << "B R-value: "; return a; }
};

class C {
  A a;
 public:
  operator A() const & { std::cout << "C C-value: "; return std::move(a); }
  operator A() & { std::cout << "C L-value: "; return std::move(a); }
  operator A() && { std::cout << "C R-value: "; return std::move(a); }
};

int main() {
  // Non-constant L-values
  B b;
  C c;
  A{b};    // B L-value: A copied
  A{c};    // C L-value: A moved

  // R-values
  A{B{}};  // B R-value: A copied
  A{C{}};  // C R-value: A moved

  // Constant L-values
  const B bc;
  const C cc;
  A{bc};   // B C-value: A copied
  A{cc};   // C C-value: A copied

  return 0;
}

假设你只想要移动特定的类成员,例如在 class C 表示短暂适配器对象的情况下,仅用于创建 struct A 实例,则 return std::move(some_member); 才有意义。
请注意,即使 class B 对象是 R 值,struct A 也总是从 class B 中被复制出来。这是因为编译器无法确定 class Bstruct A 实例是否还会被使用。在 class C 中,编译器可以通过 std::move() 获取此信息,这就是为什么 struct A 会被移动,除非 class C 的实例是常量。"最初的回答"

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接