Перемещение семантики и пересылка по ссылке в перегруженной арифметике
Я кодирую небольшую библиотеку числового анализа в С++. Я пытаюсь реализовать с использованием последних возможностей С++ 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.