Перемещение семантики и пересылка по ссылке в перегруженной арифметике

Я кодирую небольшую библиотеку числового анализа в С++. Я пытаюсь реализовать с использованием последних возможностей С++ 11, включая семантику перемещения. Я понимаю дискуссию и ответ на следующий пост: С++ 11 rvalues ​​и перемещение семантики (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. Хотя, честно говоря, достаточно быстро. Я просто хочу полностью понять семантику здесь.

Есть ли хороший С++-Гуру, который готов потратить время, чтобы объяснить, простым образом, и почему мое использование std:: move здесь плохо? Большое спасибо заранее.

Ответ 1

Вы должны предпочесть перегрузку операторов как бесплатных функций для получения полной симметрии типа (одни и те же преобразования можно применять с левой и с правой стороны). Это делает более очевидным то, что вам не хватает в этом вопросе. Переустановка вашего оператора в качестве бесплатных функций, которые вы предлагаете:

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

Но вы не можете предоставить версию, которая обрабатывает левую сторону как временную:

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

И чтобы избежать двусмысленности в коде, когда оба аргумента являются значениями r, вам необходимо предоставить еще одну перегрузку:

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

Общим советом было бы реализовать += как метод-член, который модифицирует текущий объект, а затем записать operator+ в качестве пересылки, который модифицирует соответствующий объект в интерфейсе.

Я не очень много думал об этом, но может быть альтернатива с использованием T (без ссылки r/lvalue), но я боюсь, что он не уменьшит количество перегрузок, которые вам нужно предоставить, чтобы сделать operator+ эффективно при любых обстоятельствах.

Ответ 2

Опираясь на то, что говорили другие:

  • Вызов std::move в T::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;
}

Для некоторых операторов эта "универсальная" версия будет достаточной, но поскольку добавление обычно является коммутативным, нам бы хотелось обнаружить, когда правый операнд является rvalue и изменяет его, а не перемещает/копирует левый операнд, Для этого требуется одна версия для правых операндов, которые являются 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;
}

И еще один для правых операндов, которые являются 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;
}

Наконец, вас также может заинтересовать технику, предложенную Борис Колпаков и Sumant Tambe, а также ответ .

Ответ 3

Я согласен с Дэвидом Родригесом в том, что лучше использовать функции non-member operator+, но я отложу это и сосредоточусь на вашем вопросе.

Я удивлен, что вы видите ухудшение производительности при написании

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

вместо

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

поскольку в первом случае компилятор должен иметь возможность использовать RVO для построения result в памяти для возвращаемого значения функции. В последнем случае компилятору нужно будет переместить result в возвращаемое значение функции, следовательно, потребуется дополнительная стоимость перемещения.

В общем, правила для такого рода вещей, если у вас есть функция, возвращающая объект (т.е. не ссылка):

  • Если вы возвращаете локальный объект или параметр по значению, не применяйте к нему std::move. Это позволяет компилятору выполнять RVO, что дешевле, чем копия или ход.
  • Если вы возвращаете параметр ссылки типа rvalue, примените к нему std::move. Это превращает параметр в rvalue, что позволяет компилятору перейти от него. Если вы просто вернете параметр, компилятор должен выполнить копию в возвращаемом значении.
  • Если вы возвращаете параметр универсальной ссылки (т.е. параметр "&&" выводимого типа, который может быть ссылкой rvalue или ссылкой на lvalue), примените к нему std::forward. Без него компилятор должен выполнить копию в возвращаемом значении. С его помощью компилятор может выполнить перемещение, если ссылка привязана к значению r.