Подтвердить что ты не робот

Является ли конструкция pass-by-value-and-then-move плохой идиомой?

Поскольку мы имеем семантику перемещения в С++, в настоящее время обычно выполняется

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

Полагают, что если a - значение r, копия будет удалена, и будет только один ход.

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

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

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

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

4b9b3361

Ответ 1

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

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) подавляющее большинство типов обеспечивают перемещение в постоянном времени и (b) копирование и своп обеспечивает безопасность исключений и почти оптимальную производительность в одной строке кода.

Ответ 2

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

Проблема хорошо заметна. Я бы не сказал, что конструкция pass-by-value-and-then-move - плохая идиома, но у нее определенно есть свои потенциальные проблемы.

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

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

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

Проход по ссылочному подходу 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)) { }

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

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

Ответ 3

В общем случае, когда значение будет сохранено, переходящий параметр является хорошим компромиссом -

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

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

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

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

Ответ 4

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

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

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

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

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

Плюсы:

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

Минусы:

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

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

Выводы

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

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

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

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

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

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

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

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

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

  • Типы должны сохранять ссылочные значения по соглашению, не должны передаваться по значению.
    • Каноническим примером является оболочка переадресации аргументов , определенная в ISO C++, которая требует пересылки ссылок. Обратите внимание, что в позиции вызывающего абонента может также сохраняться ссылка относительно квалификатора ref.
    • Экземпляр этого примера - std::bind. См. также разрешение LWG 817.
  • Некоторый общий код может напрямую копировать некоторые параметры. Это может быть даже без std::move, потому что стоимость копии считается игнорируемой, и перемещение не обязательно делает ее лучше.
    • Such parameters include iterators и function objects (except the case of argument forwarding caller wrappers discussed above).
    • Такие параметры включают в себя итераторы и функциональные объекты (за исключением случая обтекания вызывающей стороны для переадресации аргументов, который обсуждался выше). Обратите внимание, что шаблон конструктора std::function (но не шаблон оператора присваивания) также использует параметр функтора передачи по значению.
  • Типы, предположительно имеющие стоимость, сопоставимую с типами параметров передачи по значению с игнорируемыми затратами, также предпочтительнее, чтобы быть передачей по значению. (Иногда они используются в качестве выделенных альтернатив.) Например, экземпляры std::initializer_list и std::basic_string_view представляют собой более или менее два указателя или указатель плюс размер. Этот факт делает их достаточно дешевыми для прямой передачи без использования ссылок.
  • Некоторые типы лучше избегать передачи по значению, если только вам не нужна копия. Есть разные причины.
    • Избегайте копирования по умолчанию, потому что копия может быть довольно дорогой, или, по крайней мере, непросто гарантировать, что копия дешевая без некоторой проверки свойств времени выполнения копируемого значения. Контейнеры являются типичными примерами в этом роде.
      • Статически не зная, сколько элементов в контейнере, обычно небезопасно (например, DoS-атака DoS attack) копировать.
      • Вложенный контейнер (из других контейнеров) может легко ухудшить производительность копирования.
      • Даже пустые контейнеры не гарантированно дешевы для копирования. (Строго говоря, это зависит от конкретной реализации контейнера, например, наличия элемента "sentinel" для некоторых контейнеров на основе узлов... Но нет, не усложняйте, просто избегайте копирования по умолчанию.)
    • Избегайте копирования по умолчанию, даже если производительность совершенно не интересует, потому что могут быть некоторые неожиданные побочные эффекты.
      • В частности, контейнеры с распределением ресурсов и некоторые другие типы с обработкой, подобной распределителям ("семантика контейнера", в слово Дэвида Краусса), не должны передаваться при распространении значения - распределителя это просто еще одна большая семантическая червячная банка.
  • Несколько других типов условно зависят. Например, см. GotW # 91 для экземпляров shared_ptr. (Однако не все умные указатели таковы; observer_ptr больше похожи на необработанные указатели.)

Ответ 5

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

Как вы уже упоминали, если передается 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

Литература:

Ответ 6

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

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

Временное: 1 перемещение (копия завершена)

Lvalue: 1 копия 1 перемещение

std:: move (lvalue): 2 перемещения

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

Временное: 1 экземпляр

Lvalue: 1 экземпляр

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

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

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

Можно сказать, что в некоторых случаях (типы на основе POD) конструкция pass-by-value-and-then-move - очень плохая идея. Это на 66% медленнее, и это зависит от возможности С++ 11.

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

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

Ответ 7

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

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() копирование всегда будет выполнено.