Реализация конструктора перемещения путем вызова оператора присваивания перемещения

В статье MSDN Как написать конструктор перемещения содержится следующее рекомендация.

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

// Move constructor.
MemoryBlock(MemoryBlock&& other)
   : _data(NULL)
   , _length(0)
{
   *this = std::move(other);
}

Является ли этот код неэффективным из-за двойной инициализации значений MemoryBlock, или компилятор сможет оптимизировать дополнительные инициализации? Должен ли я всегда писать свои конструкторы перемещения, вызывая оператор присваивания перемещения?


person Elliot Hatch    schedule 14.06.2013    source источник
comment
Просто заметка. Этот подход не работает, если некоторые из ваших элементов данных не являются конструируемыми по умолчанию.   -  person Dmytro Ovdiienko    schedule 07.07.2020


Ответы (6)


[...] сможет ли компилятор оптимизировать дополнительные инициализации?

Почти во всех случаях: да.

Должен ли я всегда писать свои конструкторы перемещения, вызывая оператор присваивания перемещения?

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


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

А давайте посмотрим на какую-нибудь сборку! Это показывает точный код с веб-сайта Microsoft с обеими версиями конструктора перемещения: вручную и с помощью назначения перемещения. Вот вывод сборки для GCC с -O-O1 такой же вывод; вывод clang приводит к тому же выводу):

; ===== manual version =====           |   ; ===== via move-assig =====
MemoryBlock(MemoryBlock&&):            |   MemoryBlock(MemoryBlock&&):
    mov     QWORD PTR [rdi], 0         |       mov     QWORD PTR [rdi], 0
    mov     QWORD PTR [rdi+8], 0       |       mov     QWORD PTR [rdi+8], 0
                                       |       cmp     rdi, rsi
                                       |       je      .L1
    mov     rax, QWORD PTR [rsi+8]     |       mov     rax, QWORD PTR [rsi+8]
    mov     QWORD PTR [rdi+8], rax     |       mov     QWORD PTR [rdi+8], rax
    mov     rax, QWORD PTR [rsi]       |       mov     rax, QWORD PTR [rsi]
    mov     QWORD PTR [rdi], rax       |       mov     QWORD PTR [rdi], rax
    mov     QWORD PTR [rsi+8], 0       |       mov     QWORD PTR [rsi+8], 0
    mov     QWORD PTR [rsi], 0         |       mov     QWORD PTR [rsi], 0
                                       |   .L1:
    ret                                |       rep ret

Не считая дополнительной ветки для нужной версии, код точно такой же. Значение: повторяющиеся задания удалены.

Зачем дополнительная ветка? Оператор присваивания перемещения, определенный на странице Microsoft, выполняет больше работы, чем конструктор перемещения: он защищен от самоприсваивания. Конструктор перемещения не защищен от этого. Но: как я уже сказал, конструктор будет встроен почти во всех случаях. И в этих случаях оптимизатор может увидеть, что это не самоназначение, поэтому эта ветвь тоже будет оптимизирована.


Это часто повторяется, но важно: не делайте преждевременной микрооптимизации!

Не поймите меня неправильно, я также ненавижу программное обеспечение, которое тратит впустую много ресурсов из-за ленивых или небрежных разработчиков или управленческих решений. А энергосбережение — это не только батарейки, но и экологическая тема, которой я очень увлечен. Но преждевременная микрооптимизация не помогает в этом отношении! Конечно, держите в уме алгоритмическую сложность и удобство кэширования больших данных. Но прежде чем выполнять какую-либо конкретную оптимизацию, измерьте!

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

person Lukas Kalbertodt    schedule 08.12.2018
comment
Это просто невозможно для класса MoveConstructible, но не для класса MoveAssignable. Возникают проблемы, когда конструктор перемещения и оператор присваивания перемещения имеют разные спецификации исключений. Также обратите внимание, что это приведет к ужасным сбоям для контейнеров с поддержкой распределителя, которые имеют разные стратегии распространения распределителя для разных операций. Но корень проблемы в логике. То есть, как правило, назначение перемещения должно основываться на конструкторе перемещения, но не наоборот. Необходимость избыточной инициализации члена здесь также предполагает, что некоторые неидиоматические вещи пошли не так. - person FrankHB; 19.11.2020

Я бы не стал делать это таким образом. В первую очередь причина существования участников перемещения – производительность. Делать это для своего конструктора движений — все равно, что выкладывать мегабаксы за суперкар, а затем пытаться сэкономить, покупая обычный бензин.

Если вы хотите уменьшить количество кода, который вы пишете, просто не пишите элементы перемещения. Ваш класс будет отлично копироваться в контексте перемещения.

Если вы хотите, чтобы ваш код был высокопроизводительным, адаптируйте конструктор перемещения и назначение перемещения так, чтобы они выполнялись как можно быстрее. Члены Good Move будут невероятно быстрыми, и вы должны оценивать их скорость, подсчитывая грузы, магазины и филиалы. Если вы можете написать что-нибудь с 4 загрузками/сохранениями вместо 8, сделайте это! Если вы можете написать что-то без ветвей вместо 1, сделайте это!

Когда вы (или ваш клиент) помещаете свой класс в std::vector, для вашего типа может быть сгенерировано много ходов. Даже если ваш ход молниеносный при 8 загрузках/сохранениях, если вы можете сделать его в два раза быстрее или даже на 50% быстрее всего с 4 или 6 загрузками/сохранениями, имхо, это время потрачено не зря.

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

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

person Howard Hinnant    schedule 15.06.2013
comment
Здесь есть несколько хороших советов, но я думаю, что в этом ответе не учитывается, что стоимость дублированного кода превышает 5 минут, потраченных на его написание во второй раз - код дублирования имеет гораздо более значительные затраты, чем в удобочитаемости, ремонтопригодности и прочность. В частности, когда есть дублирующийся код, это приводит к очень распространенному типу ошибки: то есть, когда что-то исправлено или изменено в одном экземпляре кода, а не в другом. Укушенный этим больше раз, чем мне хотелось бы в своей жизни, я не стал бы дублировать код, если бы для этого не было очень веской доказанной причины. - person Don Hatch; 23.07.2014
comment
Производительность является одной из причин, но не единственной и не самой важной. В большинстве случаев, когда я пишу конструктор перемещения и оператор присваивания перемещения, это связано с тем, что мой класс имеет уникальное право собственности на ресурс. Часто производительность не является проблемой, поэтому в таких случаях имеет смысл избегать дублирования. - person opetroch; 14.10.2016
comment
@user779446: Спасибо, что поделились своими приоритетами. Вот мои: howardhinnant.github.io/coding_guidelines.html - person Howard Hinnant; 14.10.2016
comment
Дональд Кнут писал: «Программисты тратят огромное количество времени, думая или беспокоясь о скорости некритических частей своих программ, и эти попытки повысить эффективность на самом деле имеют сильное негативное влияние, когда речь идет об отладке и обслуживании. Мы должны забыть о малой эффективности, скажем, примерно в 97% случаев: преждевременная оптимизация — корень всех зол. Тем не менее, мы не должны упускать наши возможности в отношении этих критических 3%. - person Jamie; 13.04.2017
comment
Чего я не понимаю в этом ответе, так это того, что вызов присваивания перемещения, скорее всего, будет встроен компилятором и в конечном итоге сгенерирует ту же сборку, как если бы вы написали обе вручную. В подавляющем большинстве случаев... не должно быть штрафа за производительность. - person Darinth; 04.02.2020
comment
Что ж, спасибо за объяснение вашего отрицательного голоса. Я уважаю это. Большинство людей молча минусуют. В принятом ответе услужливо отображается сборка для обоих методов. Оба полностью встроены и требуют 8 перемещений QWORD. Это довольно быстро по любым меркам. Но тот, кто пишет конструктор перемещения с точки зрения присваивания, включает условную ветвь. Филиалы печально известны тем, что создают конвейерные киоски. По общему признанию, алгоритмы прогнозирования ветвлений довольно хорошо снижают стоимость остановок конвейера. Но когда вы нажмете один, цена может быть огромной по сравнению с 8 ходами. - person Howard Hinnant; 05.02.2020
comment
И то это только для MemoryBlock. Будьте осторожны, поскольку этот пример используется для обобщения всего кода. Программистов легко убедить, что всегда можно писать код таким образом, не утруждая себя измерением/тестированием. Если бы это было действительно универсально, версия = default конструктора перемещения была бы написана для этого. Но это не так. - person Howard Hinnant; 05.02.2020
comment
Приведенная стратегия разумна, но причин недостаточно. Корень гораздо тоньше: хотя правила C++ «как если» и допускают такую ​​оптимизацию, в целом она не может быть реализована, потому что реализация не может определить, какие копии точно избыточны, без дополнительной информации, предоставленной пользователями (даже с текущим C++). Таким образом, это не проблема QoI реализации языка (хотя это может быть связано с спецификацией языка). Вот почему нам нужно явно двигаться в языке. - person FrankHB; 19.11.2020
comment
Также обратите внимание, что конструктор/оператор перемещения (по крайней мере, в стандартной библиотеке) не должен указываться случайным образом. Другими словами, решение о поддержке явного перемещения зависит от дизайна API. Аргумент Кнута также не может победить эту озабоченность. - person FrankHB; 19.11.2020

Моя версия C++11 класса MemoryBlock.

#include <algorithm>
#include <vector>
// #include <stdio.h>

class MemoryBlock
{
 public:
  explicit MemoryBlock(size_t length)
    : length_(length),
      data_(new int[length])
  {
    // printf("allocating %zd\n", length);
  }

  ~MemoryBlock() noexcept
  {
    delete[] data_;
  }

  // copy constructor
  MemoryBlock(const MemoryBlock& rhs)
    : MemoryBlock(rhs.length_) // delegating to another ctor
  {
    std::copy(rhs.data_, rhs.data_ + length_, data_);
  }

  // move constructor
  MemoryBlock(MemoryBlock&& rhs) noexcept
    : length_(rhs.length_),
      data_(rhs.data_)
  {
    rhs.length_ = 0;
    rhs.data_ = nullptr;
  }

  // unifying assignment operator.
  // move assignment is not needed.
  MemoryBlock& operator=(MemoryBlock rhs) // yes, pass-by-value
  {
    swap(rhs);
    return *this;
  }

  size_t Length() const
  {
    return length_;
  }

  void swap(MemoryBlock& rhs)
  {
    std::swap(length_, rhs.length_);
    std::swap(data_, rhs.data_);
  }

 private:
  size_t length_;  // note, the prefix underscore is reserved.
  int*   data_;
};

int main()
{
   std::vector<MemoryBlock> v;
   // v.reserve(10);
   v.push_back(MemoryBlock(25));
   v.push_back(MemoryBlock(75));

   v.insert(v.begin() + 1, MemoryBlock(50));
}

С правильным компилятором C++11 MemoryBlock::MemoryBlock(size_t) следует вызывать только 3 раза в тестовой программе.

person Shuo Chen    schedule 10.10.2013
comment
Вы должны использовать std::unique_ptr‹int[]› вместо того, чтобы вручную удалять ваш массив; это сделало бы его более похожим на С++ 11 - person TiMoch; 05.03.2014
comment
Мне нравится swap() в операторе присваивания перемещения, потому что это гарантирует, что деструктор будет правильно выполнен в экземпляре объекта, тем самым высвобождая все его ресурсы. - person Julien-L; 03.03.2017
comment
Унифицирующее назначение допустимо, если элементы данных не вызывают накладных расходов на избыточное копирование при перемещении. Накладные расходы всегда существуют здесь согласно этому анализу. Хотя такие накладные расходы, возможно, достаточно малы, чтобы их можно было игнорировать в данном случае, неоптимальную реализацию трудно назвать идиоматической. Поскольку в некоторых других случаях накладными расходами можно пренебречь, также трудно получить правильные критерии (и переносимые). Это может быть большой проблемой для совместимости как с API, так и с ABI. Таким образом, я предлагаю вообще избегать унификации в специальных функциях-членах. - person FrankHB; 19.11.2020
comment
В вашем случае программист все еще должен написать два в основном похожих метода: swap() и конструктор перемещения (который в основном является специализацией swap() с пустым/неинициализированным объектом lhs). Итак, если требуется метод swap(), в любом случае ваша версия хороша. Если это не нужно, это не позволяет избежать дублирования кода! Обратите внимание, что некоторые рекомендации идут еще дальше, чтобы использовать swap() также для конструктора перемещения, сначала создавая пустой объект с помощью конструктора по умолчанию, а затем заменяя перемещенный объект. - person Kai Petzke; 27.01.2021

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

Однако я бы предпочел использовать std::forward вместо std::move, потому что это более логично:

*this = std::forward<MemoryBlock>(other);
person Scintillo    schedule 14.06.2013
comment
std::forward предназначен для использования в шаблонах, когда вы не знаете, можете ли вы перемещать объект или нет. - person aschepler; 11.10.2013
comment
Разве другая переменная уже не является значением r, поэтому нет необходимости в std::move? - person Mike Fisher; 21.01.2014
comment
@MikeFisher Нет, именованное rvalue является lvalue - person TiMoch; 05.03.2014

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

  // Free the existing resource.
  delete[] _data;

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

person Jonathan Potter    schedule 15.06.2013
comment
Вам не нужно было бы инициализировать значение дважды, если бы вы использовали список инициализаторов :_data(other._data) - person Elliot Hatch; 15.06.2013
comment
Нет, но тогда вы повторно реализуете код перемещения, а не вызываете оператор присваивания, о котором был ваш вопрос. - person Jonathan Potter; 16.06.2013

Я бы просто исключил инициализацию члена и написал бы:

MemoryBlock(MemoryBlock&& other)
{
   *this = std::move(other);
}

Это всегда будет работать, если только при назначении перемещения не возникнут исключения, а обычно это не так!

Преимущества этого стиля:

  1. Вам не нужно беспокоиться о том, будет ли компилятор дважды инициализировать члены, потому что это может различаться в разных средах.
  2. Вы пишете меньше кода.
  3. Вам не нужно обновлять его, даже если вы добавите в класс дополнительных участников в будущем.
  4. Компиляторы часто могут встроить назначение перемещения, поэтому накладные расходы конструктора копирования будут минимальными.

Я думаю, что сообщение @Howard не совсем отвечает на этот вопрос. На практике классы часто не любят копирование, многие классы просто отключают конструктор копирования и присваивание копии. Но большинство классов можно перемещать, даже если они не копируются.

person Jackie Ku    schedule 22.09.2016
comment
Оператор перемещения-присваивания может освободить ресурсы этого объекта перед тем, как похитить ресурсы исходного объекта. Если вы не инициализируете члены в конструкторе перемещения, то они будут содержать несколько мусорных значений, и оператор присваивания перемещения попытается их освободить. - person 4LegsDrivenCat; 11.09.2020