移动语义和运算符重载

9
这与Matthieu M.提供的如何利用移动语义进行+运算符重载(通常是不直接将左参数重新赋值的运算符)这个答案有关。
他建议实现三种不同的重载:
inline T operator+(T left, T const& right) { left += right; return left; }
inline T operator+(T const& left, T right) { right += left; return right; } // commutative
inline T operator+(T left, T&& right) { left += right; return left; } // disambiguation

第1和第3个有意义,但我不明白第2个的目的。注释中提到了可交换处理,但似乎1和2是互斥的(即实现两者会导致歧义)

例如,如果全部实现:

T a, b, c;
c = a + b;

编译器输出:

1>          错误 C2593: 'operator +' 有歧义
1>          可能是 'T operator +(const T &,T)'
1>          或       'T operator +(T,const T &)'
1>          当试图匹配参数列表 '(T, T)' 时

如果删除1或2中的任何一个,程序将按预期工作。由于1是一般情况,而2仅在使用交换运算符时才能正确工作,因此我不知道为什么会使用2。我是否漏掉了什么?

2个回答

12

我认为您并没有漏掉任何内容 - 您问题中的代码确实存在问题。他回答的前半部分还有意义,但在“四种所需情况”和实际示例之间失去了一些东西。

以下可能更好:

inline T operator+(T left, T const& right) { left += right; return left; }
inline T operator+(const T& left, T&& right) { right += left; return right; }

这个实现遵循了以下规则:制作一个LHS的副本(最好通过移动构造方式),除非RHS已经到期,此时就在原地修改。

对于非交换操作符,省略第二个重载,否则提供一个不委托复合赋值的实现。

如果你的类内嵌有重量级资源(以至于不能高效移动),你需要避免按值传递。Daniel在他的答案中提出了一些很好的观点。但是不要像他建议的那样返回T&&,因为那是一个悬空引用。


我说如果op(left, right)不是可交换的,那么只需忽略第二个重载(const T&left,T && right)就可以正确处理吗? - helloworld922
@helloworld922:是的,没错。在这种情况下,右侧无法就地修改,因此第二个重载没有速度优势。 - Ben Voigt
如果两个都是rvalues,哪一个会被调用?我们可以添加另一个 inline T operator+(T&&,T&&) 并根据哪个资源更大来选择重用哪一个吗? - balki
@balki:在我的情况下,第二个重载被调用。是的,您的建议可以起作用——它是否值得取决于您的数据类型的具体情况。 - Ben Voigt
难道不应该是“inline T operator+(const T& left, T&& right) { right += left; return std::move(right); }”吗?因为在函数内部,right是一个左值,所以返回语句会复制right。 - Dominic Hofer

6

关于此答案的重要更新/警告!

实际上,有一个令人信服的例子在合理的真实世界代码中使用以下内容会悄悄地创建一个悬空引用。即使付出一些额外的暂存开销,请使用其他答案的技术来避免这个问题。我将保留此答案以供参考。


可交换情况下的正确重载如下:

T   operator+( const T& lhs, const T& rhs )
{
  T nrv( lhs );
  nrv += rhs;
  return nrv;
}

T&& operator+( T&& lhs, const T& rhs )
{
  lhs += rhs;
  return std::move( lhs );
}

T&& operator+( const T& lhs, T&& rhs )
{
  rhs += lhs;
  return std::move( rhs );
}

T&& operator+( T&& lhs, T&& rhs )
{
  lhs += std::move( rhs );
  return std::move( lhs );
}

为什么会这样,它是如何工作的?首先,请注意,如果以右值引用作为参数,则可以修改并返回它。产生该引用的表达式需要保证在完成表达式的结束之前,包括operator+,右值不会被析构。这也意味着operator+可以简单地将右值引用返回给调用者,因为调用者需要在完全评估表达式并且临时变量(右值)被析构之前使用operator+的结果(它是同一表达式的一部分)。
第二个重要观察是,这如何节省更多的临时对象和移动操作。考虑以下表达式:
T a, b, c, d; // initialized somehow...

T r = a + b + c + d;

通过上面的操作,相当于:
T t( a );    // T operator+( const T& lhs, const T& rhs );
t += b;      // ...part of the above...
t += c;      // T&& operator+( T&& lhs, const T& rhs );
t += d;      // T&& operator+( T&& lhs, const T& rhs );
T r( std::move( t ) ); // T&& was returned from the last operator+

将此与另一种方法进行比较:

T t1( a );   // T operator+( T lhs, const T& rhs );
t1 += b;     // ...part of the above...
T t2( std::move( t1 ) ); // t1 is an rvalue, so it is moved
t2 += c;
T t3( std::move( t2 ) );
t3 += d;
T r( std::move( t3 );

这意味着您仍然有三个临时变量,尽管它们被移动而不是复制,但上述方法在避免临时变量方面更为高效。
对于完整的库,包括对noexcept的支持,请参见df.operators。在那里,您还将找到非交换情况和混合类型操作的版本。
这是一个完整的测试程序来测试它:
#include <iostream>
#include <utility>

struct A
{
  A() { std::cout << "A::A()" << std::endl; }
  A( const A& ) { std::cout << "A::A(const A&)" << std::endl; }
  A( A&& ) { std::cout << "A::A(A&&)" << std::endl; }
  ~A() { std::cout << "A::~A()" << std::endl; }

  A& operator+=( const A& ) { std::cout << "+=" << std::endl; return *this; }
};

// #define BY_VALUE
#ifdef BY_VALUE
A operator+( A lhs, const A& rhs )
{
  lhs += rhs;
  return lhs;
}
#else
A operator+( const A& lhs, const A& rhs )
{
  A nrv( lhs );
  nrv += rhs;
  return nrv;
}

A&& operator+( A&& lhs, const A& rhs )
{
  lhs += rhs;
  return std::move( lhs );
}
#endif

int main()
{
  A a, b, c, d;
  A r = a + b + c + d;
}

1
@BenVoigt 不,它不会。试一下!(我已经厌倦了听到这篇文章和每个人都认为传值解决了所有问题。它没有。再说一遍:试一下。) - Daniel Frey
我能理解为什么拥有所有4个重载函数会被认为更完整和高效,因为可以避免一些临时变量,但我认为在许多情况下它非常高效的说法有些夸张,因为大多数时间我需要移动大量堆数据和很少需要为每个临时变量单独初始化其他数据。当然,像任何优化一样,应该进行基准测试以评估任何真正的性能提升。 - helloworld922
1
@helloworld922 请不要这样引用我的话。我说的是“在避免临时变量方面更加高效”,而不是“在许多情况下更加高效”。在某些情况下,您可能看不到差异,因为后续进行了其他优化和/或因为您的用例根本无法从中受益,但重要的是,我的版本永远不会不够高效,所以它永远不会有害。此外,使用库甚至意味着您不需要关心。专注于正确使用operator + =,将其余部分留给df.operators - Daniel Frey
1
@BenVoigt,你读过Dave文章中的“现实问题”部分吗?即使编译器将来能够进行这种优化(特别是对于真正重要的非平凡情况),也会给优化通道带来很大压力。我的技术今天就可以使用(在GCC和Clang上测试过)。 - Daniel Frey
1
@DanielFrey:一个不能像每个内置类型和每个标准库类型那样使用的运算符重载是有问题的。 - Ben Voigt
显示剩余14条评论

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