如何移动一个被const返回的对象?

7
最近,我一直在阅读这篇文章那篇文章,建议停止返回const对象。Stephan T. Lavavej在Going Native 2013中的演讲中也提出了这个建议。
我编写了一个非常简单的测试来帮助我理解所有这些情况下调用哪个构造函数/运算符:
  • 返回const或非const对象
  • 如果Return Value Optimization (RVO)启动会怎么样?
  • 如果Named Return Value Optimization (NRVO)启动会怎么样?
以下是测试:
#include <iostream>

void println(const std::string&s){
    try{std::cout<<s<<std::endl;}
    catch(...){}}

class A{
public:
    int m;
    A():m(0){println("    Default Constructor");}
    A(const A&a):m(a.m){println("    Copy Constructor");}
    A(A&&a):m(a.m){println("    Move Constructor");}
    const A&operator=(const A&a){m=a.m;println("    Copy Operator");return*this;}
    const A&operator=(A&&a){m=a.m;println("    Move Operator");return*this;}
    ~A(){println("    Destructor");}
};

A nrvo(){
    A nrvo;
    nrvo.m=17;
    return nrvo;}

const A cnrvo(){
    A nrvo;
    nrvo.m=17;
    return nrvo;}

A rvo(){
    return A();}

const A crvo(){
    return A();}

A sum(const A&l,const A&r){
    if(l.m==0){return r;}
    if(r.m==0){return l;}
    A sum;
    sum.m=l.m+r.m;
    return sum;}

const A csum(const A&l,const A&r){
    if(l.m==0){return r;}
    if(r.m==0){return l;}
    A sum;
    sum.m=l.m+r.m;
    return sum;}

int main(){
    println("build a");A a;a.m=12;
    println("build b");A b;b.m=5;
    println("Constructor nrvo");A anrvo=nrvo();
    println("Constructor cnrvo");A acnrvo=cnrvo();
    println("Constructor rvo");A arvo=rvo();
    println("Constructor crvo");A acrvo=crvo();
    println("Constructor sum");A asum=sum(a,b);
    println("Constructor csum");A acsum=csum(a,b);
    println("Affectation nrvo");a=nrvo();
    println("Affectation cnrvo");a=cnrvo();
    println("Affectation rvo");a=rvo();
    println("Affectation crvo");a=crvo();
    println("Affectation sum");a=sum(a,b);
    println("Affectation csum");a=csum(a,b);
    println("Done");
    return 0;
}

这是发布模式下的输出结果(启用了NRVO和RVO):

build a
    Default Constructor
build b
    Default Constructor
Constructor nrvo
    Default Constructor
Constructor cnrvo
    Default Constructor
Constructor rvo
    Default Constructor
Constructor crvo
    Default Constructor
Constructor sum
    Default Constructor
    Move Constructor
    Destructor
Constructor csum
    Default Constructor
    Move Constructor
    Destructor
Affectation nrvo
    Default Constructor
    Move Operator
    Destructor
Affectation cnrvo
    Default Constructor
    Copy Operator
    Destructor
Affectation rvo
    Default Constructor
    Move Operator
    Destructor
Affectation crvo
    Default Constructor
    Copy Operator
    Destructor
Affectation sum
    Copy Constructor
    Move Operator
    Destructor
Affectation csum
    Default Constructor
    Move Constructor
    Destructor
    Copy Operator
    Destructor
Done
    Destructor
    Destructor
    Destructor
    Destructor
    Destructor
    Destructor
    Destructor
    Destructor

我不理解的是: 为什么在“Constructor csum”测试中使用移动构造函数?

返回对象是const,所以我真的觉得它应该调用复制构造函数。

我错过了什么吗?

这不应该是编译器的错误,Visual Studio和clang都给出相同的输出。


什么是uint4?由于这个原因,它在这里无法编译,这让我想知道你在这里呈现的代码是否是你编译和执行的代码... - PlasmaHH
抱歉,它只是一个整数。我修改了问题。 - Arnaud
2
通过具有不同变量的多个返回选项来汇总块nrvo。 - Yakk - Adam Nevraumont
它将 A sum 移动到返回的 const A 中,然后复制被省略了。 - Simple
4个回答

4
我不理解的是:为什么在“构造函数 csum”测试中使用移动构造函数?
在这种情况下,编译器允许进行[N]RVO(返回值优化),但它没有这样做。第二好的方法是移动构造返回的对象。
返回的对象是const,所以我真的觉得应该调用拷贝构造函数。
这完全没有关系。但我想这并不完全明显,所以让我们走一遍概念上的返回值和[N]RVO是什么。为此,最简单的方法是忽略返回的对象:
T f() {
   T obj;
   return obj;   // [1] Alternatively: return T();
}
void g() {
   f();          // ignore the value
}

在标记为[1]的行中,将本地/临时对象的副本复制到返回值中。即使完全忽略该值也是如此,这就是您在上面的代码中所练习的内容。

如果您不忽略返回值,就像这样:

T t = f();

概念上有一个从返回值到本地变量 t 的第二个副本。在所有情况下都省略了第二份副本。

对于第一份副本,被返回的对象是否为const并不重要,编译器根据 [概念复制/移动] 构造函数的参数确定该如何处理,而不是正在构造的对象是否会成为const。 这与以下语句相同:

// a is convertible to T somehow
const T ct(a);
T t(a);

无论目标对象是否为const,编译器都需要根据参数找到最佳的构造函数,而不是根据目标对象。现在回到你的练习,为了确保不调用复制构造函数,你需要修改return语句的参数。
A force_copy(const A&l,const A&r){ // A need not be `const`
    if(l.m==0){return r;}
    if(r.m==0){return l;}
    const A sum;
    return sum;
}

这应该会触发复制构造函数,但是如果编译器认为适合的话,它也可能完全省略复制过程。


1
根据我的观察,移动构造函数优先于拷贝构造函数。正如Yakk所说,由于有多个返回路径,你不能省略移动构造函数。

http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2002/n1377.htm#Copy%20vs%20Move

rvalues will prefer rvalue references. lvalues will prefer lvalue references. CV qualification conversions are considered secondary relative to r/l-value conversions. rvalues can still bind to a const lvalue reference (const A&), but only if there is not a more attractive rvalue reference in the overload set. lvalues can bind to an rvalue reference, but will prefer an lvalue reference if it exists in the overload set. The rule that a more cv-qualified object can not bind to a less cv-qualified reference stands ... both for lvalue and rvalue references.

A further language refinement can be made at this point. When returning a non-cv-qualified object with automatic storage from a function, there should be an implicit cast to rvalue:

string
operator+(const string& x, const string& y)
{
    string result;
    result.reserve(x.size() + y.size());
    result = x;
    result += y;
    return result;  // as if return static_cast<string&&>(result);
}

The logic resulting from this implicit cast results in an automatic hierarchy of "move semantics" from best to worst:

If you can elide the move/copy, do so (by present language rules)
Else if there is a move constructor, use it
Else if there is a copy constructor, use it
Else the program is ill formed
如果你在参数中删除了const &,那又怎样呢?它仍将调用移动构造函数,但将为参数调用复制构造函数。如果你返回一个常量对象呢?它将为本地变量调用复制构造函数。如果你返回一个const &呢?它也会调用复制构造函数。

1

我对编译后的二进制文件进行了反汇编(VC12 release build, O2),我的结论是:

move操作是将csum(a,b)中的结果移动到堆栈分配的const A临时对象中,以便作为稍后A& operator=(const A&)的参数使用。

move操作无法移动cv限定的变量,但在从csum返回之前,sum变量仍然是非常量变量,因此可以被移动;并且需要在返回后进行移动,以便稍后使用。

const修饰符只是禁止编译器在返回后进行移动,但不禁止在csum内部进行移动。如果从csum中删除const,则结果将为:

Default Constructor
Move Constructor
Destructor
Move Operator
Destructor

顺便提一下,你的测试程序有一个错误,将导致a = sum(a, b);不正确,A的默认构造函数应该是:

A() : m(3) { println("    Default Constructor"); }

否则,您会发现很难解释 a = sum(a, b); 的输出结果。


以下我将尝试分析调试版本的ASM。结果是相同的。(分析发布版本就像自杀一样>_<)
主函数:
  a = csum(a, b);
00F66C95  lea         eax,[b]  
00F66C98  push        eax                           ;; param b
00F66C99  lea         ecx,[a]  
00F66C9C  push        ecx                           ;; param a
00F66C9D  lea         edx,[ebp-18Ch]  
00F66CA3  push        edx                           ;; alloc stack space for return value
00F66CA4  call        csum (0F610DCh)  
00F66CA9  add         esp,0Ch  
00F66CAC  mov         dword ptr [ebp-194h],eax  
00F66CB2  mov         eax,dword ptr [ebp-194h]  
00F66CB8  mov         dword ptr [ebp-198h],eax  
00F66CBE  mov         byte ptr [ebp-4],5  
00F66CC2  mov         ecx,dword ptr [ebp-198h]  
00F66CC8  push        ecx  
00F66CC9  lea         ecx,[a]  
00F66CCC  call        A::operator= (0F61136h)       ;; assign to var a in main()
00F66CD1  mov         byte ptr [ebp-4],3  
00F66CD5  lea         ecx,[ebp-18Ch]  
00F66CDB  call        A::~A (0F612A8h) 

csum:

  if (l.m == 0) {
00F665AA  mov         eax,dword ptr [l]  
00F665AD  cmp         dword ptr [eax],0  
00F665B0  jne         csum+79h (0F665D9h)  
    return r;
00F665B2  mov         eax,dword ptr [r]  
00F665B5  push        eax                            ;; r pushed as param for \
00F665B6  mov         ecx,dword ptr [ebp+8]  
00F665B9  call        A::A (0F613F2h)                ;; copy ctor of A
00F665BE  mov         dword ptr [ebp-4],0  
00F665C5  mov         ecx,dword ptr [ebp-0E4h]  
00F665CB  or          ecx,1  
00F665CE  mov         dword ptr [ebp-0E4h],ecx  
00F665D4  mov         eax,dword ptr [ebp+8]  
00F665D7  jmp         csum+0EEh (0F6664Eh)  
  }
  if (r.m == 0) {
00F665D9  mov         eax,dword ptr [r]  
00F665DC  cmp         dword ptr [eax],0  
00F665DF  jne         csum+0A8h (0F66608h)  
    return l;
00F665E1  mov         eax,dword ptr [l]  
00F665E4  push        eax                             ;; l pushed as param for \
00F665E5  mov         ecx,dword ptr [ebp+8]  
00F665E8  call        A::A (0F613F2h)                 ;; copy ctor of A
00F665ED  mov         dword ptr [ebp-4],0  
00F665F4  mov         ecx,dword ptr [ebp-0E4h]  
00F665FA  or          ecx,1  
00F665FD  mov         dword ptr [ebp-0E4h],ecx  
00F66603  mov         eax,dword ptr [ebp+8]  
00F66606  jmp         csum+0EEh (0F6664Eh)  
  }
  A sum;
00F66608  lea         ecx,[sum]  
  A sum;
00F6660B  call        A::A (0F61244h)                  ;; ctor of result sum
00F66610  mov         dword ptr [ebp-4],1  
  sum.m = l.m + r.m;
00F66617  mov         eax,dword ptr [l]  
00F6661A  mov         ecx,dword ptr [eax]  
00F6661C  mov         edx,dword ptr [r]  
00F6661F  add         ecx,dword ptr [edx]  
00F66621  mov         dword ptr [sum],ecx  
  return sum;
00F66624  lea         eax,[sum]  
00F66627  push        eax                              ;; sum pushed as param for \
00F66628  mov         ecx,dword ptr [ebp+8]  
00F6662B  call        A::A (0F610D2h)                  ;; move ctor of A (this one is pushed in main as a temp variable on stack)
00F66630  mov         ecx,dword ptr [ebp-0E4h]  
00F66636  or          ecx,1  
00F66639  mov         dword ptr [ebp-0E4h],ecx  
00F6663F  mov         byte ptr [ebp-4],0  
00F66643  lea         ecx,[sum]  
00F66646  call        A::~A (0F612A8h)                 ;; dtor of sum
00F6664B  mov         eax,dword ptr [ebp+8]  
}

1
答案是,你的本地变量 A sum 被移动到函数返回的 const A 中(这是移动构造函数的输出),然后从返回值复制到 A acsum 的副本被编译器省略了(因此没有复制构造函数的输出)。

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