Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Move Semantics and Pass-by-Rvalue-Reference in Overloaded Arithmetic

I am coding a small numeric analysis library in C++. I have been trying to implement using the latest C++11 features including move semantics. I understand the discussion and top answer at the following post: C++11 rvalues and move semantics confusion (return statement) , but there is one scenario that I still am trying to wrap my head around.

I have a class, call it T, which is fully equipped with overloaded operators. I also have both copy and move constructors.

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

My client code heavily uses operators, so I am trying to ensure that complex arithmetic expressions get maximum benefit from move semantics. Consider the following:

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

Without move semantics, my operators are making a new local variable using the copy constructor each time, so there are a total of 4 copies. I was hoping that with move semantics I could reduce this to 2 copies plus some moves. In the parenthesized version:

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

each of (b * c) and (d / e) must create the temporary in the usual way with a copy, but then it would be great if I could leverage one of those temporaries to accumulate the remaining results with only moves.

Using g++ compiler, I have been able to do this, but I suspect my technique may not be safe and I want to fully understand why.

Here is an example implementation for the addition operator:

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);
}

Without the calls to std::move, then only the const & version of each operator is ever invoked. But when using std::move as above, subsequent arithmetic (after the innermost expressions) are performed using the && version of each operator.

I know that RVO can be inhibited, but on very computationally-expensive, real-world problems it seems that the gain slightly outweighs the lack of RVO. That is, over millions of computations I do get a very tiny speedup when I include std::move. Though in all honesty it is fast enough without. I really just want to fully comprehend the semantics here.

Is there a kind C++ Guru who is willing to take the time to explain, in a simple way, whether and why my use of std::move is a bad thing here? Many thanks in advance.

like image 519
Tientuinë Avatar asked Oct 31 '12 19:10

Tientuinë


3 Answers

You should prefer overloading the operators as free functions to obtain full type symmetry (same conversions can be applied on the left and right hand side). That makes it a bit more obvious what you are missing from the question. Restating your operator as free functions you are offering:

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

But you are failing to provide a version that handles the left hand side being a temporary:

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

And to avoid ambiguities in the code when both arguments are rvalues you need to provide yet another overload:

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

The common advice would be to implement += as a member method that modifies the current object, and then write operator+ as a forwarder that modifies the appropriate object in the interface.

I have not really thought this much, but there might be an alternative using T (no r/lvalue reference), but I fear that it will not reduce the number of overloads you need to provide to make operator+ efficient in all circumstances.

like image 137
David Rodríguez - dribeas Avatar answered Nov 15 '22 18:11

David Rodríguez - dribeas


To build on what others have said:

  • The call to std::move in T::operator+( T const & ) is unnecessary and could prevent RVO.
  • It would be preferable to provide a non-member operator+ that delegates to T::operator+=( T const & ).

I'd also like to add that perfect forwarding can be used to reduce the number of non-member operator+ overloads required:

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;
}

For some operators this "universal" version would be sufficient, but since addition is typically commutative we'd probably like to detect when the right-hand operand is an rvalue and modify it rather than moving/copying the left-hand operand. That requires one version for right-hand operands that are lvalues:

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;
}

And another for right-hand operands that are rvalues:

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;
}

Finally, you may also be interested in a technique proposed by Boris Kolpackov and Sumant Tambe as well as Scott Meyers' response to the idea.

like image 44
Andrew Durward Avatar answered Nov 15 '22 18:11

Andrew Durward


I agree with David Rodríguez that it'd be a better design to use non-member operator+ functions, but I'll set that aside and focus on your question.

I'm surprised that you see a performance degradation when writing

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

instead of

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

because in the former case, the compiler should be able to use RVO to construct result in the memory for the function's return value. In the latter case, the compiler would need to move result into the function's return value, hence incur the extra cost of the move.

In general, the rules for this kind of thing are, assuming you have a function returning an object (i.e., not a reference):

  • If you're returning a local object or a by-value parameter, don't apply std::move to it. That permits the compiler to perform RVO, which is cheaper than a copy or a move.
  • If you're returning a parameter of type rvalue reference, apply std::move to it. That turns the parameter into an rvalue, hence permitting the compiler to move from it. If you just return the parameter, the compiler must perform a copy into the return value.
  • If you're returning a parameter that's a universal reference (i.e., a "&&" parameter of deduced type that could be an rvalue reference or an lvalue reference), apply std::forward to it. Without it, the compiler must perform a copy into the return value. With it, the compiler can perform a move if the reference is bound to an rvalue.
like image 25
KnowItAllWannabe Avatar answered Nov 15 '22 17:11

KnowItAllWannabe