12

我正在用 C++ 编写一个小型数值分析库。我一直在尝试使用包括移动语义在内的最新 C++11 特性来实现。我理解以下帖子中的讨论和最佳答案:C++11 rvalues and move semantics chaos (return statement),但有一种情况我仍在尝试解决。

我有一个类,叫做它T,它完全配备了重载运算符。我也有复制和移动构造函数。

T (const T &) { /*initialization via copy*/; }
T (T &&) { /*initialization via move*/; }

我的客户端代码大量使用运算符,因此我试图确保复杂的算术表达式从移动语义中获得最大收益。考虑以下:

T a, b, c, d, e;
T f = a + b * c - d / e;

如果没有移动语义,我的操作员每次都使用复制构造函数创建一个新的局部变量,因此总共有 4 个副本。我希望通过移动语义我可以将其减少到 2 个副本加上一些移动。在带括号的版本中:

T f = a + (b * c) - (d / e);

每个(b * c)并且(d / e)必须以通常的方式使用副本创建临时对象,但是如果我可以利用其中一个临时对象来仅通过移动来累积剩余的结果,那就太好了。

使用 g++ 编译器,我已经能够做到这一点,但我怀疑我的技术可能不安全,我想完全理解为什么。

这是加法运算符的示例实现:

T operator+ (T const& x) const
{
    T result(*this);
    // logic to perform addition here using result as the target
    return std::move(result);
}
T operator+ (T&& x) const
{
    // logic to perform addition here using x as the target
    return std::move(x);
}

如果没有对 的调用std::move,则只会const &调用每个运算符的版本。但是当std::move如上所述使用时,随后的算术(在最里面的表达式之后)是使用&&每个运算符的版本执行的。

我知道 RVO 可以被抑制,但在计算量非常大的现实问题上,似乎收益略高于 RVO 的缺乏。也就是说,经过数百万次计算,当我包含std::move. 虽然老实说,没有它就足够快了。我真的只是想完全理解这里的语义。

有没有好心的 C++ 大师愿意花时间以简单的方式解释我在这里使用 std::move 是否以及为什么是一件坏事?提前谢谢了。

4

3 回答 3

8

您应该更喜欢将运算符重载为自由函数以获得完整的类型对称性(可以在左侧和右侧应用相同的转换)。这使得问题中遗漏的内容更加明显。将您的运营商重述为您提供的免费功能:

T operator+( T const &, T const & );
T operator+( T const &, T&& );

但是你没有提供一个版本来处理左侧是一个临时的:

T operator+( T&&, T const& );

当两个参数都是右值时,为了避免代码中的歧义,您需要提供另一个重载:

T operator+( T&&, T&& );

常见的建议是实现+=为修改当前对象的成员方法,然后编写operator+为修改接口中适当对象的转发器。

我并没有真正考虑这么多,但可能有一个替代使用T(没有 r/lvalue 引用),但我担心它不会减少您需要提供的重载数量以operator+在所有情况下提高效率。

于 2012-10-31T19:55:36.693 回答
5

建立在其他人所说的基础上:

  • 调用std::moveinT::operator+( T const & )是不必要的,可能会阻止 RVO。
  • 最好提供一个operator+代表T::operator+=( T const & ).

我还想补充一点,可以使用完美转发来减少所需的非成员operator+重载的数量:

template< typename L, typename R >
typename std::enable_if<
  std::is_convertible< L, T >::value &&
  std::is_convertible< R, T >::value,
  T >::type operator+( L && l, R && r )
{
  T result( std::forward< L >( l ) );
  result += r;
  return result;
}

对于某些运算符,这个“通用”版本就足够了,但由于加法通常是可交换的,我们可能希望检测右侧操作数何时是右值并修改它,而不是移动/复制左侧操作数。这需要一个用于左值右手操作数的版本:

template< typename L, typename R >
typename std::enable_if<
  std::is_convertible< L, T >::value &&
  std::is_convertible< R, T >::value &&
  std::is_lvalue_reference< R&& >::value,
  T >::type operator+( L && l, R && r )
{
  T result( std::forward< L >( l ) );
  result += r;
  return result;
}

另一个用于右值的右手操作数:

template< typename L, typename R >
typename std::enable_if<
  std::is_convertible< L, T >::value &&
  std::is_convertible< R, T >::value &&
  std::is_rvalue_reference< R&& >::value,
  T >::type operator+( L && l, R && r )
{
  T result( std::move( r ) );
  result += l;
  return result;
}

最后,您可能还对Boris KolpackovSumant Tambe提出的技术以及 Scott Meyers对该想法的回应感兴趣。

于 2012-11-02T21:59:47.203 回答
3

我同意 David Rodríguez 的观点,即使用非成员operator+函数会是一个更好的设计,但我将把它放在一边,专注于你的问题。

我很惊讶您在编写时看到性能下降

T operator+(const T&)
{
  T result(*this);
  return result;
}

代替

T operator+(const T&)
{
  T result(*this);
  return std::move(result);
}

因为在前一种情况下,编译器应该能够使用 RVOresult在内存中构造函数的返回值。在后一种情况下,编译器需要移动result到函数的返回值中,因此会产生移动的额外成本。

一般来说,这种事情的规则是,假设你有一个函数返回一个对象(即,不是引用):

  • 如果您要返回本地对象或按值参数,请不要应用std::move它。这允许编译器执行 RVO,这比复制或移动便宜。
  • 如果您要返回右值引用类型的参数,std::move请对其应用。这会将参数转换为右值,从而允许编译器从中移动。如果只返回参数,编译器必须执行一个复制到返回值。
  • 如果您要返回一个通用引用的参数(即,&&可能是右值引用或左值引用的推导类型的“”参数),std::forward请应用它。没有它,编译器必须执行复制到返回值。有了它,如果引用绑定到右值,编译器就可以执行移动。
于 2012-10-31T22:54:50.123 回答