Является ли конструкция "передача по значению и перемещению" плохой идиомой?

Так как у нас есть семантика перемещения в C++, в настоящее время это обычно делается

void set_a(A a) { _a = std::move(a); }

Причина в том, что если a является значением, копия будет удалена, и будет только один ход.

Но что произойдет, если a такое lvalue? Кажется, что будет конструкция копирования и затем назначение перемещения (при условии, что у A есть надлежащий оператор назначения перемещения). Перемещение назначений может быть дорогостоящим, если объект имеет слишком много переменных-членов.

С другой стороны, если мы сделаем

void set_a(const A& a) { _a = a; }

Будет только одно назначение копирования. Можно ли сказать, что этот способ предпочтительнее идиомы передачи по значению, если мы передадим lvalues?

5 ответов

Решение

Типы дорогих в перемещении редки в современном использовании C++. Если вас беспокоит стоимость переезда, запишите обе перегрузки:

void set_a(const A& a) { _a = a; }
void set_a(A&& a) { _a = std::move(a); }

или установщик совершенной пересылки:

template <typename T>
void set_a(T&& a) { _a = std::forward<T>(a); }

который будет принимать lvalues, rvalues ​​и все остальное, неявно преобразуемое в decltype(_a) не требуя дополнительных копий или ходов.

Несмотря на то, что при настройке из lvalue требуется дополнительный ход, идиома неплохая, поскольку (а) подавляющее большинство типов обеспечивает перемещения в постоянном времени и (б) копирование и замена обеспечивают исключительную безопасность и почти оптимальную производительность в одном строка кода.

Но что произойдет, если a такое lvalue? Кажется, что будет конструкция копирования и затем назначение перемещения (при условии, что у A есть надлежащий оператор назначения перемещения). Перемещение назначений может быть дорогостоящим, если объект имеет слишком много переменных-членов.

Проблема хорошо заметна. Я бы даже не сказал, что конструкция "передача по значению и перемещению" - плохая идиома, но в ней есть свои потенциальные подводные камни.

Если ваш тип стоит дорого, и / или его перемещение - это просто копия, то подход с передачей по значению является неоптимальным. Примеры таких типов могут включать типы с массивом фиксированного размера в качестве члена: перемещение может быть относительно дорогим, а перемещение - просто копией. Смотрите также

в данном контексте.

Преимущество метода передачи по значению заключается в том, что вам нужно поддерживать только одну функцию, но вы платите за это производительностью. От вашего приложения зависит, перевешивает ли это преимущество обслуживания потери производительности.

Использование ссылок на lvalue и rvalue может быстро привести к головной боли при обслуживании, если у вас есть несколько аргументов. Учти это:

#include <vector>
using namespace std;

struct A { vector<int> v; };
struct B { vector<int> v; };

struct C {
  A a;
  B b;
  C(const A&  a, const B&  b) : a(a), b(b) { }
  C(const A&  a,       B&& b) : a(a), b(move(b)) { }
  C(      A&& a, const B&  b) : a(move(a)), b(b) { }
  C(      A&& a,       B&& b) : a(move(a)), b(move(b)) { }  
};

Если у вас есть несколько аргументов, у вас будет проблема с перестановкой. В этом очень простом примере, вероятно, все еще не так уж и плохо поддерживать эти 4 конструктора. Однако уже в этом простом случае я бы серьезно подумал об использовании метода передачи по значению с одной функцией

C(A a, B b) : a(move(a)), b(move(b)) { }

вместо вышеуказанных 4 конструкторов.

Короче говоря, ни один подход не лишен недостатков. Принимайте решения, основываясь на фактической информации профилирования, а не на преждевременной оптимизации.

Текущие ответы довольно неполны. Вместо этого я попытаюсь сделать вывод, основываясь на списках плюсов и минусов, которые я найду.

Короткий ответ

Короче говоря, это может быть хорошо, но иногда плохо.

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

Детальный анализ

Плюсы:

  • Для каждого списка параметров требуется только одна функция.
    • Действительно, требуется только одна, а не несколько обычных перегрузок (или даже 2 n перегрузок, когда у вас есть n параметров, когда каждый из них может быть неквалифицированным или const-qualified).
    • Как и в шаблоне пересылки, параметры, передаваемые по значению, совместимы не только с const, но volatile, которые уменьшают еще больше обычных перегрузок.
      • В сочетании с пулей выше, вам не нужно 4 n перегрузок для обслуживания {unqulified, const, const, const volatile } комбинации для n параметров.
    • По сравнению с шаблоном пересылки, это может быть не шаблонная функция, если параметры не являются обязательными (параметризуются через параметры типа шаблона). Это позволяет создавать определения вне строки, а не определения шаблона, для каждого экземпляра в каждой единице перевода, что может значительно улучшить производительность времени перевода (как правило, во время компиляции и компоновки).
    • Это также облегчает реализацию других перегрузок (если таковые имеются).
      • Если у вас есть шаблон пересылки для типа объекта параметра T может конфликтовать с перегрузками, имеющими параметр const T& в той же позиции, потому что аргумент может быть lvalue типа T и шаблон создан с типом T& (скорее, чем const T&) поскольку это может быть более предпочтительным правилом перегрузки, когда нет другого способа определить, кто является наилучшим кандидатом на перегрузку. Это несоответствие может быть довольно удивительным.
      • В частности, учтите, что у вас есть конструктор шаблонов пересылки с одним параметром типа P&& в классе C, Сколько раз вы забудете исключить случай P&& от возможно cv-квалификации C по SFINAE (например, добавив typename = enable_if_t<!is_same<C, decay_t<P>> в список шаблонов параметров), чтобы убедиться, что он не конфликтует с конструкторами копирования / перемещения (даже если последние явно предоставлены пользователем)?
  • Поскольку параметр передается по значению не ссылочного типа, он может принудительно передать аргумент в качестве значения prvalue. Это может иметь значение, когда аргумент имеет тип литерала класса. Считай, есть такой класс со статическим constexpr элемент данных, объявленный в некотором классе без определения вне класса, когда он используется в качестве аргумента параметра ссылочного типа lvalue, он может в конечном итоге не установить связь, потому что он используется в odr и его определения нет,

Минусы:

  • Объединяющий интерфейс не может заменить конструкторы копирования и перемещения, если тип объекта параметра идентичен классу. В противном случае инициализация копирования параметра будет бесконечной рекурсией, потому что он вызовет объединяющий конструктор, а затем вызовет сам себя.
  • Как уже упоминалось в других ответах, если стоимость копирования не является игнорируемой (дешевой и достаточно предсказуемой), это означает, что вы почти всегда будете испытывать ухудшение производительности в вызовах, когда копия не нужна, потому что инициализация копирования объединения прошла Параметр -by-value безоговорочно представляет копию (скопированную или перенесенную) аргумента, если не исключено.
    • Даже с обязательным исключением, начиная с C++17, инициализация копирования объекта параметра по-прежнему вряд ли может быть свободно удалена - если только реализация не очень старается доказать, что поведение не изменилось в соответствии с правилами "как будто", а не с выделенным назначением копирования здесь применимы правила, которые иногда могут быть невозможны без анализа всей программы.
    • Аналогичным образом, стоимость уничтожения также не может быть проигнорирована, особенно если принять во внимание нетривиальные подобъекты (например, в случае контейнеров). Разница в том, что он применяется не только к инициализации копирования, введенной конструкцией копирования, но и конструкцией перемещения. Создание перемещения дешевле, чем копирование в конструкторах, не может улучшить ситуацию. Чем больше стоимость инициализации копирования, тем больше затрат на уничтожение вы можете себе позволить.
  • Небольшим недостатком является то, что невозможно настроить интерфейс по-разному, так как множественные перегрузки, например, указание различных noexcept -спецификаторы для параметров const& а также && квалифицированные типы.
    • OTOH, в этом примере унифицирующий интерфейс обычно предоставляет вам noexcept(false) копия + noexcept переместить, если вы укажете noexcept или всегда noexcept(false) когда вы ничего не указываете (или явно noexcept(false)). (Обратите внимание, в первом случае, noexcept не предотвращает выброс во время копирования, потому что это произойдет только во время оценки аргументов, которые находятся вне тела функции.) Больше нет возможности настраивать их отдельно.
    • Это считается второстепенным, потому что это не часто требуется в реальности.
    • Даже если такие перегрузки используются, они, вероятно, сбивают с толку по своей природе: различные спецификаторы могут скрывать тонкие, но важные поведенческие различия, о которых трудно рассуждать. Почему не разные имена вместо перегрузок?
    • Обратите внимание на пример noexcept может быть особенно проблематичным, так как C++17, потому что noexcept -спецификация теперь влияет на тип функции. (Некоторые неожиданные проблемы совместимости могут быть диагностированы предупреждением Clang++.)

Иногда безусловная копия действительно полезна. Поскольку состав операций с гарантией строгого исключения не содержит гарантии по своей природе, копия может использоваться в качестве держателя транснационального государства, когда требуется гарантия строгого исключения и операция не может быть разбита на последовательность операций с не менее строгим (без исключения или сильная) гарантия исключения. (Это включает в себя идиому копирования и замены, хотя назначения не рекомендуется унифицировать по другим причинам в целом, см. Ниже.) Однако это не означает, что в противном случае копия является неприемлемой. Если целью интерфейса всегда является создание какого-либо объекта типа T и стоимость переезда T игнорируется, копия может быть перемещена к цели без нежелательных накладных расходов.

Выводы

Таким образом, для некоторых заданных операций, есть предложения относительно того, следует ли использовать унифицирующий интерфейс для их замены:

  1. Если не все типы параметров соответствуют унифицирующему интерфейсу или существует поведенческая разница, отличная от стоимости новых копий среди унифицируемых операций, не может быть объединяющего интерфейса.
  2. Если следующие условия не соответствуют всем параметрам, не может быть объединяющего интерфейса. (Но он все еще может быть разбит на разные именованные функции, делегируя один вызов другому.)
  3. Для любого параметра типа T, если копия каждого аргумента необходима для всех операций, используйте унификацию.
  4. Если и скопировать и переместить конструкцию T иметь игнорируемую стоимость, используйте унификацию.
  5. Если целью интерфейса всегда является создание какого-либо объекта типа T, а стоимость переезда строительство T игнорируется, используйте объединяющий.
  6. В противном случае, избегайте объединения.

Вот несколько примеров, которые нужно избегать объединения:

  1. Операции присваивания (включая присвоение их подобъектам, обычно с идиомой копирования и замены) для T без учета затрат на конструкцию копирования и перемещения не соответствует критериям унификации, поскольку целью присвоения является не созданиезамена содержимого) объекта. Скопированный объект в конечном итоге будет разрушен, что приведет к ненужным накладным расходам. Это еще более очевидно для случаев самостоятельного назначения.
  2. Вставка значений в контейнер не соответствует критериям, если только инициализация и уничтожение при копировании не обходятся без затрат. Если операция завершается неудачно (из-за ошибки выделения, дублирующихся значений и т. Д.) После инициализации копирования, параметры должны быть уничтожены, что приводит к ненужным накладным расходам.
  3. Условное создание объекта на основе параметров повлечет за собой накладные расходы, когда оно фактически не создает объект (например, std::map::insert_or_assign -подобная вставка контейнера даже несмотря на вышеприведенный сбой).

Обратите внимание, что точный предел "игнорируемых" затрат несколько субъективен, поскольку в конечном итоге он зависит от того, сколько затрат допустимо для разработчиков и / или пользователей, и может варьироваться в зависимости от конкретного случая.

Практически, я (консервативно) предполагаю, что любой тривиально копируемый и тривиально разрушаемый тип, размер которого составляет не более одного машинного слова (например, указатель), квалифицирует критерии игнорируемой стоимости в целом - если полученный код действительно стоит слишком дорого в таком случае, он указывает на то, что используется неверная конфигурация инструмента сборки или цепочка инструментов не готова к производству.

Сделайте профиль, если есть дальнейшие сомнения в производительности.

Дополнительное тематическое исследование

Есть несколько других хорошо известных типов, которые предпочитают передавать значениями условно:

  • Общий код в стиле STL может напрямую копировать некоторые параметры. Может быть даже без std::move потому что предполагается, что стоимость копии игнорируется, а перемещение не обязательно делает ее лучше. К таким параметрам относятся итераторы и функциональные объекты (кроме случая (аргумента) перенаправления обертки вызывающего абонента).
  • Типы, предположительно имеющие стоимость, сопоставимую с типами параметров передачи по значению с игнорируемыми затратами, также являются предпочтительными. (Иногда они используются как выделенные альтернативы.) Например, случаи std::initializer_list а также std::basic_string_view более или менее два указателя или указатель плюс размер. Этот факт делает их достаточно дешевыми, чтобы их можно было напрямую передавать без ссылок.

Аналогично, некоторые типы должны избегать передачи по значениям, если вам не нужна копия. Контейнеры являются типичными примерами в этом роде. * В частности, контейнеры с выделенным распределителем и некоторые другие типы с аналогичным подходом к распределителям ("семантика контейнера", по словам Дэвида Краусса), не должны передаваться по значению, даже если производительность совершенно неинтересна - распространение распределителя является просто еще одним большой семантический червь может.

Несколько других типов условно зависит. Например, см. GotW #91 для shared_ptr экземпляров. (Однако не все умные указатели таковы; observer_ptr больше похоже на сырые указатели.)

Для общего случая, когда значение будет сохранено, передача только по значению является хорошим компромиссом.

Для случая, когда вы знаете, что будут переданы только lvalues ​​(некоторый тесно связанный код), это неразумно, неумно.

Для случая, когда можно подозревать улучшение скорости, предоставляя оба, сначала ДУМАЙТЕ ДВАЖДЫ, и если это не помогло, ИЗМЕРИТЕ.

Где значение не будет сохранено, я предпочитаю передачу по ссылке, потому что это предотвращает множество ненужных операций копирования.

Наконец, если программирование может быть сведено к бездумному применению правил, мы можем оставить это роботам. Так что ИМХО не стоит так сильно концентрироваться на правилах. Лучше сосредоточиться на преимуществах и издержках для разных ситуаций. Затраты включают не только скорость, но также, например, размер кода и четкость. Правила обычно не могут справиться с такими конфликтами интересов.

Передача по значению, затем перемещение на самом деле хорошая идиома для объектов, которые, как вы знаете, являются подвижными.

Как вы упомянули, если передается значение rvalue, оно либо удалит копию, либо будет перемещено, а затем в конструкторе оно будет перемещено.

Вы можете перегрузить конструктор копирования и явно переместить конструктор, однако это становится более сложным, если у вас есть более одного параметра.

Рассмотрим пример,

class Obj {
  public:

  Obj(std::vector<int> x, std::vector<int> y)
      : X(std::move(x)), Y(std::move(y)) {}

  private:

  /* Our internal data. */
  std::vector<int> X, Y;

};  // Obj

Предположим, что если вы хотите предоставить явные версии, вы получите 4 конструктора, например:

class Obj {
  public:

  Obj(std::vector<int> &&x, std::vector<int> &&y)
      : X(std::move(x)), Y(std::move(y)) {}

  Obj(std::vector<int> &&x, const std::vector<int> &y)
      : X(std::move(x)), Y(y) {}

  Obj(const std::vector<int> &x, std::vector<int> &&y)
      : X(x), Y(std::move(y)) {}

  Obj(const std::vector<int> &x, const std::vector<int> &y)
      : X(x), Y(y) {}

  private:

  /* Our internal data. */
  std::vector<int> X, Y;

};  // Obj

Как видите, с увеличением количества параметров число необходимых конструкторов растет в перестановках.

Если у вас нет конкретного типа, но есть шаблонизированный конструктор, вы можете использовать совершенную пересылку следующим образом:

class Obj {
  public:

  template <typename T, typename U>
  Obj(T &&x, U &&y)
      : X(std::forward<T>(x)), Y(std::forward<U>(y)) {}

  private:

  std::vector<int> X, Y;

};   // Obj

Рекомендации:

  1. Хотите скорость? Передать по значению
  2. Приправа С ++

Я отвечаю сам, потому что я постараюсь обобщить некоторые из ответов. Сколько ходов / копий у нас в каждом случае?

(A) Передайте по значению и переместите конструкцию присваивания, передав параметр X. Если Х является...

Временно: 1 ход (копия удаляется)

Lvalue: 1 копия 1 ход

std::move(lvalue): 2 хода

(B) Передача по ссылке и копирование присваивания обычной (до C++11) конструкции. Если Х является...

Временно: 1 копия

Lvalue: 1 копия

std::move(lvalue): 1 копия

Можно предположить, что три вида параметров одинаково вероятны. Таким образом, каждые 3 звонка мы имеем (A) 4 хода и 1 копию или (B) 3 копии. То есть, в среднем, (A) 1,33 хода и 0,33 копии на вызов или (B) 1 копия на вызов.

Если мы сталкиваемся с ситуацией, когда наши классы состоят в основном из POD, ходы так же дороги, как и копии. Таким образом, у нас будет 1,66 копий (или ходов) на каждый вызов для сеттера в случае (A) и 1 копия в случае (B).

Можно сказать, что в некоторых обстоятельствах (типы, основанные на POD), конструкция "передача по значению и затем перемещение" - очень плохая идея. Это на 66% медленнее и зависит от функции C++ 11.

С другой стороны, если наши классы включают контейнеры (которые используют динамическую память), (A) должен быть намного быстрее (кроме случаев, когда мы в основном передаем lvalues).

Пожалуйста, поправьте меня, если я ошибаюсь.

Читаемость в декларации:

void foo1( A a ); // easy to read, but unless you see the implementation 
                  // you don't know for sure if a std::move() is used.

void foo2( const A & a ); // longer declaration, but the interface shows
                          // that no copy is required on calling foo().

Спектакль:

A a;
foo1( a );  // copy + move
foo2( a );  // pass by reference + copy

Обязанности:

A a;
foo1( a );  // caller copies, foo1 moves
foo2( a );  // foo2 copies

Для типичного встроенного кода обычно нет никакой разницы при оптимизации. Но foo2() может выполнять копирование только при определенных условиях (например, вставить в карту, если ключ не существует), тогда как для foo1() копирование всегда будет выполнено.

Другие вопросы по тегам