Урок №191. Конструктор перемещения и Оператор присваивания перемещением

  Юрий  | 

  |

  Обновл. 24 Янв 2022  | 

 74442

 ǀ   15 

На уроке №189 мы говорили об std::auto_ptr, обсуждали необходимость использования семантики перемещения и рассмотрели некоторые недостатки, которые возникают при переопределении функций, использующих семантику копирования (конструктор копирования и оператор присваивания копированием), для работы с семантикой перемещения.

На этом уроке мы рассмотрим более детально, как C++11 решил эти проблемы с помощью конструктора перемещения и оператора присваивания перемещением.

Конструктор копирования и оператор присваивания копированием

Давайте немного поговорим о семантике копирования.

Конструктор копирования используется для инициализации класса путем создания копии необходимого объекта. Оператор присваивания копированием (или «копирующее присваивание») используется для копирования одного класса в другой (существующий) класс. По умолчанию язык C++ автоматически предоставляет конструктор копирования и оператор присваивания копированием, если вы не предоставили их сами. Предоставляемые компилятором функции выполняют поверхностное копирование, что может вызывать проблемы у классов, которые работают с динамически выделенной памятью. Одним из вариантов решения таких проблем является переопределение конструктора копирования и оператора присваивания копированием для выполнения глубокого копирования.

Возвращаясь к нашему примеру с классом Auto_ptr (который является умным указателем) из предыдущих уроков, давайте рассмотрим версию этого класса, в которой конструктор копирования и оператор присваивания копированием переопределены для выполнения глубокого копирования:

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

Результат выполнения программы:

Item acquired
Item acquired
Item destroyed
Item acquired
Item destroyed
Item destroyed

Рассмотрим выполнение этой программы детально. Здесь происходит 6 ключевых действий (по одному на каждую строку вывода):

   При создании объекта mainItem внутри generateItem() создается локальная переменная (объект) item и инициализируется динамически выделенным Item, вследствие чего мы получаем первую строку вывода — Item acquired.

   Затем item возвращается обратно в функцию main() по значению. Почему по значению? Потому что item является локальной переменной и её нельзя вернуть по адресу или по ссылке, так как item будет уничтожена при завершении выполнения функции generateItem(). Таким образом, item — это копия (временный объект). Поскольку наш конструктор копирования выполняет глубокое копирование, то выделяется новый Item, результатом чего является вторая строка вывода — Item acquired.

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

   Временный объект, созданный вследствие глубокого копирования, присваивается mainItem в функции main() путем использования оператора присваивания копированием. Поскольку мы перегрузили оператор присваивания копированием для выполнения глубокого копирования (вместо поверхностного), то выделяется новый Item, вследствие чего мы получаем четвертую строку вывода — Item acquired.

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

   В конце функции main() объект mainItem выходит из области видимости, и мы получаем шестую (и последнюю) строку вывода — Item destroyed.

Примечание: Ваш результат может состоять из 4 строк вывода, если ваш компилятор игнорирует возвращаемое значение из функции generateItem():

Item acquired
Item acquired
Item destroyed
Item destroyed

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

Неэффективно, скажете вы, но, по крайней мере, всё работает без сбоев! Однако с семантикой перемещения мы можем добиться большего.

Конструктор перемещения и оператор присваивания перемещением


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

Определение конструктора перемещения и оператора присваивания перемещением выполняется аналогично определению конструктора копирования и оператора присваивания копированием. Однако, в то время как функции с копированием принимают в качестве параметра константную ссылку l-value, функции с перемещением принимают в качестве параметра неконстантную ссылку r-value.

Вот вышеприведенный класс Auto_ptr3, но уже с добавленными конструктором перемещения и оператором присваивания перемещением. Мы не стали удалять конструктор копирования и оператор присваивания копированием:



Всё просто! Вместо выполнения глубокого копирования исходного объекта в неявный объект, мы просто перемещаем (воруем) ресурсы исходного объекта. Под этим подразумевается поверхностное копирование указателя на исходный объект в неявный (временный) объект, а затем присваивание исходному указателю значения null (точнее nullptr) и в конце удаление неявного объекта.

Результат выполнения программы:

Item acquired
Item destroyed

Уже гораздо лучше!

Ход выполнения этой программы точно такой же, как и предыдущей программы. Однако вместо вызова конструктора копирования и оператора присваивания копированием в этой программе вызывается конструктор перемещения и оператор присваивания перемещением. Рассмотрим детально:

   При создании объекта mainItem внутри generateItem() создается локальная переменная (объект) item и инициализируется динамически выделенным Item, вследствие чего мы получаем первую строку вывода — Item acquired.

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

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

   В функции main() временный объект с помощью оператора присваивания перемещением перемещается в mainItem.

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

   В конце функции main() объект mainItem выходит из области видимости, и мы получаем вторую (и последнюю) строку вывода — Item destroyed.

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

Когда вызываются конструктор перемещения и оператор присваивания перемещением?

Конструктор перемещения и оператор присваивания перемещением вызываются, когда аргументом для создания или присваивания является r-value. Чаще всего этим r-value будет литерал или временное значение (временный объект).

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

Правило: Если вам нужен конструктор перемещения и оператор присваивания перемещением, которые выполняют перемещение (а не копирование), то вам их нужно предоставить (написать) самостоятельно.

Ключевое понимание семантики перемещения


Если мы создаем объект или выполняем присваивание, где аргументом является l-value, то единственное разумное, что мы можем сделать — это скопировать l-value. Мы не можем сказать, что изменять l-value безопасно, так как он может использоваться в программе позже. Если у нас есть выражение a = b, то нам бы очень не хотелось, чтобы b каким-либо образом был изменен.

Однако, если мы создаем объект или выполняем присваивание, где аргументом является r-value, то мы знаем, что r-value — это просто некоторый временный объект. Вместо того, чтобы копировать его (что может быть затратно), мы можем просто переместить его ресурсы (что не так затратно) в другой объект, который мы создаем или которому присваиваем текущий. Это безопасно, поскольку временный объект будет уничтожен в конце выражения в любом случае, поэтому мы можем быть уверены, что он никогда не будет повторно использован!

В C++11 через ссылки r-value мы можем изменять поведение функций в зависимости от того, чем является аргумент: r-value или l-value. А это, в свою очередь, позволяет нам принимать более разумные и эффективные решения о том, как должен работать наш код.

В примерах, приведенных выше, в конструкторе перемещения и перегрузке оператора присваивания мы присваивали для x.m_ptr значение nullptr. Это может показаться лишним — в конце концов, если x является временным r-value, то зачем нам беспокоиться о выполнении какой-либо «очистки», если параметр x все равно будет уничтожен?

Дело в том, что, когда x выходит из области видимости, вызывается деструктор для уничтожения x.m_ptr. Если в этот момент x.m_ptr все еще указывает на тот же объект, что и m_ptr, то m_ptr превратится в висячий указатель после уничтожения x.m_ptr. Когда объект, содержащий m_ptr, в конечном итоге будет использован (или уничтожен), то мы получим неопределенное поведение/результаты.

На следующем уроке мы рассмотрим ситуации, в которых параметром x является l-value. В таких случаях x не будет немедленно уничтожен, и его еще можно будет использовать некоторое время.

Использование семантики перемещения с l-values

В функции generateItem() класса Auto_ptr4 вышеприведенного примера, когда переменная item возвращается по значению, её ресурсы перемещаются, а не копируются, даже если item — это l-value. В языке C++ есть правило, согласно которому автоматические объекты, возвращаемые функцией по значению, можно перемещать (а не копировать), даже если они являются l-values. Это имеет смысл, так как item все равно будет уничтожен в конце функции в любом случае!

Отключение копирования


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

Если вы попытаетесь передать l-value в Auto_ptr5 по значению, то компилятор будет жаловаться, что конструктор копирования, необходимый для инициализации аргумента конструктора копирования, был удален. Это хорошо, поскольку мы должны передавать l-value в Auto_ptr5 по константной ссылке в любом случае!

Наконец, Auto_ptr5 — это отличный пример класса умного указателя. Больше того, Стандартная библиотека С++ имеет класс, очень похожий на этот (который вы должны использовать) — std::unique_ptr. Детально о std::unique_ptr мы поговорим на соответствующих уроках.

Семантика копирования vs. Семантика перемещения

Рассмотрим другой класс, который использует динамически выделенную память — простой шаблон класса DynamicArray. Этот класс имеет конструктор копирования и оператор присваивания копированием, которые выполняют глубокое копирование:

Теперь давайте попробуем использовать этот класс на практике. Например, давайте выделим массив (объект класса DynamicArray), который будет хранить миллион целочисленных значений. Чтобы проверить эффективность этого кода, мы будем использовать класс Timer, который разрабатывали на уроке №129. Классом Timer мы определим скорость выполнения нашего кода и покажем разницу в производительности между семантикой копирования и семантикой перемещения.

Результат выполнения программы на компьютере автора в режиме «Release»:

0.0225438

Примечание: Результат измеряется в секундах.

Теперь давайте запустим эту же программу, заменив конструктор копирования и оператор присваивания копированием на конструктор перемещения и оператор присваивания перемещением:

Результат выполнения программы на компьютере автора в режиме «Release»:

0.0131518

Сравниваем время выполнения двух программ: 0.0131518 / 0.0225438 = 58.3%. Версия с использованием семантики перемещения была почти на 42% быстрее версии с использованием семантики копирования!

Оценить статью:

Звёзд: 1Звёзд: 2Звёзд: 3Звёзд: 4Звёзд: 5 (160 оценок, среднее: 4,92 из 5)
Загрузка...

Комментариев: 15

  1. Олександр:

    Что то я не понимаю как здесь конструктор перемещения работает.

    Конструктор перемещения вызывается у анонимного объекта создаваемого на месте функции generateItem() в стейтементе mainItem = generateItem();.  В этот конструктор в качестве параметра передается объект item, возвращённый из функции generateItem() и этот объект является l-value. Но параметром конструктора является ссылка r-value, что означает что мы можем предать только r-value. Так как это работает?

    1. Vlad:

      Боюсь ошибиться но видимо дело в этом:

      В функции generateItem() класса Auto_ptr4 вышеприведенного примера, когда переменная item возвращается по значению, её ресурсы перемещаются, а не копируются, даже если item — это l-value. В языке C++ есть правило, согласно которому автоматические объекты, возвращаемые функцией по значению, можно перемещать (а не копировать), даже если они являются l-values. Это имеет смысл, так как item все равно будет уничтожен в конце функции в любом случае!

  2. Сергей:

    Спасибо. Отличный урок. Олин из самых важных в понимании новых стандартов C++. К этому уроку буду еще возвращаться…

  3. Gunter:

    Непонятен комментарий к первому примеру ( с функцией generateItem() ):

    При создании объекта mainItem внутри generateItem() создается локальная переменная (объект) item и инициализируется динамически выделенным Item, вследствие чего мы получаем первую строку вывода — Item acquired.

    Я так понимаю, что имелось ввиду: "При вызове generateItem() создается динамически выделенный анонимный объект типа Item для инициализации локальной переменной (объекта) item. При создании этого динамического объекта получаем первую строку вывода — Item acquired."

    Правильно я понимаю последовательность?

    1. ArtemF:

      Да. Если более подробно. Оператор new возвращает указатель на динамическую память, в конструкторе Item() память инициализируется, и потом локальная переменная item инициализируется этим объектом.

  4. Александр:

    Сравнение времени ИМХО некорректно в последнем примере.
    Выглядит будто выигрыш от перемещения "всего в 2 раза", что не так уж и впечатляет. А выглядит так потому, что мы в замер времени включаем еще кучу всего лишнего (создание массива, заполнение его и т.д.)

    Более правильно было бы расставить отметки времени для различных действий. В моих замерах для массивов размером 1е8 вышло примерно так:
    создание + заполнение массива — 460мс
    копирование — 400мс (причем копирование оператором = занимает стабильно на ~5мс меньше, чем оператором копирования)
    перемещение — 0-1мс (ожидаемо)

    Ну и можно асимптотику процессов сравнить. Копирование выполняется за время o(n) (пропорционально длине массива), а перемещение за o(1) (не зависит от размера массива)

  5. Александр:

    Так и не удалось мне вызвать конструктор перемещением в своем компиляторе 🙁

    Причем функция с сигнатурой void (Auto_ptr) продолжает работать с аргументами r-value даже если удалить конструкторы (const Ap&) и (Ap&&)

    1. Александр:

      Зато очень интересно работает генератор. Если создавать именованную переменную при запрещенной только (const Ap&), то все работает (при этом никаких конструкторов не вызывается)

      А вот если запретить еще и (Ap&&), то компилятор ругается на удаленный (const Ap&)

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

  6. Ivan:

    Насколько я понимаю, в последнем примере используется присваивание перемещения в строке

    т.о. dbl перемещается в arr вместо присваивания копированием. Конструктор перемещения ведь не используется?

    1. Simon:

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

      В данном случае cloneArrayAndDouble(arr) является r-value, поэтому в указанной строке будет вызван оператор присвоения перемещением.

  7. Mariia:

    огромное спасибо! вы меня спасли с написанием собственного умного указателя

  8. Илья:

    Походу я ещё не раз вернусь к этому уроку, столько прикольного кода, и ещё,есть идея, а что если хранить количество классов(пользователей),которые владеют данными, и, пока не останется последний пользователь,не возвращать память в ОС, как нибудь попробую это реализовать….

    1. Andrik:

      На сколько я помню, хотя я тоже новачек в с++, это не нужно реализовывать, это уже реализовано в std::shared_ptr.

  9. kmish:

    Спасибо за труды! Данный урок для меня был одним из самых сложных в данном туториале.

    1. Фото аватара Юрий:

      Пожалуйста 🙂

Добавить комментарий для Сергей Отменить ответ

Ваш E-mail не будет опубликован. Обязательные поля помечены *