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

  Юрий  | 

    | 

  Обновл. 28 Июн 2019  | 

 6464

 ǀ   5 

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

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

  1. Аватар Mariia:

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

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

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

    1. Аватар Andrik:

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

  3. Аватар kmish:

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

    1. Юрий Юрий:

      Пожалуйста 🙂

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

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