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

  Юрий  | 

  Обновл. 23 Авг 2020  | 

 24237

 ǀ   7 

На уроке №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 (69 оценок, среднее: 4,96 из 5)
Загрузка...

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

  1. Аватар Ivan:

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

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

  2. Аватар Валерий:

    Большое спасибо за урок, вопрос по результирующему тесту:
    Что за режим release? Можно подробнее, чей компилятор, какая оптимизация была включена? Дебажные символы выключены, профилировка тоже? Интересует именно чистый тест полученного кода на O(0), O(2), O(3).

  3. Аватар Mariia:

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

  4. Аватар Илья:

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

    1. Аватар Andrik:

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

  5. Аватар kmish:

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

    1. Юрий Юрий:

      Пожалуйста 🙂

Добавить комментарий

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