cpp-copying-and-moving/

Мне лично в языке C++ всегда казалась довольно сложной для понимания тема всех эти copy assignment’ов, move constructor’ов, perfect forwarding’а и вот этого всего. Поскольку без этих знаний в современном C++ далеко не уедешь, решил попробовать во всем разобраться. Не могу сказать, что теперь владею материалом в совершенстве, но на небольшую заметку-введение вроде наскреблось. Авось кому будет интересно.

Базовый код с запретом копирования и присваивания

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

#include <iostream>

class Coord2D {
public :
Coord2D ( ) {
_x = 0 ;
_y = 0 ;
std :: cout << «Coord2D(x = » << _x << «, y = » << _y <<
«) created» << std :: endl ;
}

Coord2D ( int x, int y ) {
_x = x ;
_y = y ;
std :: cout << «Coord2D(x = » << _x << «, y = » << _y <<
«) created» << std :: endl ;
}

~Coord2D ( ) {
std :: cout << «Coord2D(x = » << _x << «, y = » << _y <<
«) destroyed» << std :: endl ;
}

int getX ( ) const { return _x ; }
int getY ( ) const { return _y ; }
Coord2D & setX ( int x ) { _x = x ; return * this ; }
Coord2D & setY ( int y ) { _y = y ; return * this ; }

Coord2D ( Coord2D const & ) = delete ;
void operator = ( Coord2D const & ) = delete ;
private :
int _x, _y ;
} ;

int main ( ) {
Coord2D c1 ;
Coord2D c2 ( 1 , 2 ) ;

std :: cout << «Hi!» << std :: endl ;
}

Вывод программы:

Coord2D(x = 0, y = 0) created
Coord2D(x = 1, y = 2) created
Hi!
Coord2D(x = 1, y = 2) destroyed
Coord2D(x = 0, y = 0) destroyed

Пока что никаких неожиданностей. Стоит отметить, что вместо:

Coord2D ( Coord2D const & ) = delete ;
void operator = ( Coord2D const & ) = delete ;

… можно написать:

Coord2D ( Coord2D const & ) = default ;
void operator = ( Coord2D const & ) = default ;

… тем самым явно указав на то, что вас устраивают реализации по умолчанию.

Copy constructor

Объявим copy contructor:

/* … */

Coord2D ( Coord2D const & obj ) {
_x = obj._x ;
_y = obj._y ;
std :: cout << «Coord2D(x = » << _x << «, y = » << _y <<
«) copied» << std :: endl ;
}

/* … */

int main ( ) {
Coord2D c1 ( 1 , 2 ) ;
Coord2D c2 ( c1 ) ;
Coord2D c3 = c1 ;
std :: cout << «Hi!» << std :: endl ;
}

Заметьте, что в нем мы имеем доступ к private полям второго экземпляра класса (obj), несмотря на то, что это другой экземпляр. Вывод программы:

Coord2D(x = 1, y = 2) created
Coord2D(x = 1, y = 2) copied
Coord2D(x = 1, y = 2) copied
Hi!
Coord2D(x = 1, y = 2) destroyed
Coord2D(x = 1, y = 2) destroyed
Coord2D(x = 1, y = 2) destroyed

Оба синтаксиса эквивалентны, в обоих случаях был вызван copy constructor. Конструктор для каждого объекта был вызван один раз. Можно было и не писать этот код, так как реализация copy constructor по умолчанию и так просто копирует атрибуты класса.

Copy assignment

Объявим copy assignment оператор:

/* … */

void operator = ( Coord2D const & obj ) {
_x = obj._x ;
_y = obj._y ;
std :: cout << «Coord2D(x = » << _x << «, y = » << _y <<
«) copy-assigned» << std :: endl ;
}

/* … */

int main ( ) {
Coord2D c1 ( 1 , 2 ) ;
Coord2D c2 ( c1 ) ;
Coord2D c3 = c1 ;

c2 = c3 ;

std :: cout << «Hi!» << std :: endl ;
}

Вывод:

Coord2D(x = 1, y = 2) created
Coord2D(x = 1, y = 2) copied
Coord2D(x = 1, y = 2) copied
Coord2D(x = 1, y = 2) copy-assigned
Hi!
Coord2D(x = 1, y = 2) destroyed
Coord2D(x = 1, y = 2) destroyed
Coord2D(x = 1, y = 2) destroyed

Заметьте, что деструктор при присвоении не вызывается. Это означает, что в реализации copy assignment следует освобождать старые ресурсы перед присвоением новых значений.

Move constructor

Перепишем код следующим образом:

/* … */

Coord2D id ( Coord2D x ) {
std :: cout << «id called» << std :: endl ;
return x ;
}

int main ( ) {
Coord2D c1 = id ( Coord2D ( 1 , 2 ) ) ;
c1. setX ( 1 ) ;
std :: cout << «Hi!» << std :: endl ;
}

Вывод:

Coord2D(x = 1, y = 2) created
id called
Coord2D(x = 1, y = 2) copied
Coord2D(x = 1, y = 2) destroyed
Hi!
Coord2D(x = -1, y = 2) destroyed

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

/* … */
Coord2D ( Coord2D && obj ) {
_x = obj._x ;
_y = obj._y ;
std :: cout << «Coord2D(x = » << _x << «, y = » << _y <<
«) moved» << std :: endl ;
}
/* … */

Вывод:

Coord2D(x = 1, y = 2) created
id called
Coord2D(x = 1, y = 2) moved
Coord2D(x = 1, y = 2) destroyed
Hi!
Coord2D(x = -1, y = 2) destroyed

Move constructor вызывается вместо copy constructor в случае, когда объект, из которого создается копия, вот-вот будет уничтожен. В таком конструкторе обычно данные из временного объекта переносятся в новый объект, а полям временного объекта присваиваются nullptr или что-то такое. Важно понимать, что при выходе из move constructor оба объекта должны оставаться валидными и для обоих должен корректно отрабатывать деструктор. Ссылка T&& называется rvalue reference и означает ссылку на объект, который вот-вот будет уничтожен.

Move assignment

Аналогично move constructor, только для присваивания. Например, код:

int main ( ) {
Coord2D c1 ( 1 , 2 ) ;
c1 = Coord2D ( 4 , 5 ) ;

std :: cout << «Hi!» << std :: endl ;
}

… выведет:

Coord2D(x = 1, y = 2) created
Coord2D(x = 4, y = 5) created
Coord2D(x = 4, y = 5) copy-assigned
Coord2D(x = 4, y = 5) destroyed
Hi!
Coord2D(x = 4, y = 5) destroyed

Объявим move assignment оператор:

/* … */
void operator = ( Coord2D && obj ) {
_x = obj._x ;
_y = obj._y ;
std :: cout << «Coord2D(x = » << _x << «, y = » << _y <<
«) move-assigned» << std :: endl ;
}
/* … */

Вывод:

Coord2D(x = 1, y = 2) created
Coord2D(x = 4, y = 5) created
Coord2D(x = 4, y = 5) move-assigned
Coord2D(x = 4, y = 5) destroyed
Hi!
Coord2D(x = 4, y = 5) destroyed

Move assignment оператор позволяет применить те же оптимизации, что и move constructor. В move constructor поля объекта, переданного в качестве аргумента, обычно как-то зануляются. В move assignment лучше сделать swap полей в двух объектах. Это позволит избавиться от дублирования кода между оператором move assignment и деструктором.

std::move

Move constructor бывает трудно стригерить. Например, код:

int main ( ) {
Coord2D c1 ( Coord2D ( 1 , 2 ) . setX ( 5 ) ) ;
std :: cout << «Hi!» << std :: endl ;
}

… выведет:

Coord2D(x = 1, y = 2) created
Coord2D(x = 5, y = 2) copied
Coord2D(x = 5, y = 2) destroyed
Hi!
Coord2D(x = 5, y = 2) destroyed

Так происходит, потому что метод setX возвращает lvalue reference, а у move constructor на входе совершенно другой тип, rvalue reference. Чтобы явно показать, что временный объект мы больше использовать не будем, предусмотрен std:move. Если переписать код так:

int main ( ) {
Coord2D c1 ( std :: move ( Coord2D ( 1 , 2 ) . setX ( 5 ) ) ) ;
std :: cout << «Hi!» << std :: endl ;
}

… программа выведет:

Coord2D(x = 1, y = 2) created
Coord2D(x = 5, y = 2) moved
Coord2D(x = 5, y = 2) destroyed
Hi!
Coord2D(x = 5, y = 2) destroyed

В сущности, std::move просто кастует lvalue reference (T&) в rvalue reference (T&&), больше ничего. При чтении кода std::move как бы говорит нам, что мы отдаем владение объектом в этом месте и далее не собираемся его использовать.

std::forward

Шаблон std::forward предназначен исключительно для написания шаблонных методов, способных принимать на вход как lvalue, так и rvalue, в зависимости от того, что передал пользователь, и передавать соответствующий тип далее без изменений. Техника получила название perfect forwarding.

Рассмотрим пример. Определим оператор сложения двух координат:

/* … */

template < class T >
friend Coord2D operator + ( T && a, const Coord2D & b ) {
std :: cout << «Creating `Coord2D t`…» << std :: endl ;
Coord2D t ( std :: forward < T > ( a ) ) ;
std :: cout << «`Coord2D t` created!» << std :: endl ;

return t. setX ( t. getX ( ) + b. getX ( ) ) . setY ( t. getY ( ) + b. getY ( ) ) ;
}

/* … */

int main ( ) {
Coord2D c1 ( 1 , 1 ) , c2 ( 1 , 2 ) , c3 ( 1 , 3 ) ;
Coord2D c4 = c1 + c2 + c3 ;

std :: cout << «Hi!» << std :: endl ;
}

Вывод:

Coord2D(x = 1, y = 1) created
Coord2D(x = 1, y = 2) created
Coord2D(x = 1, y = 3) created
Creating `Coord2D t`…
Coord2D(x = 1, y = 1) copied
`Coord2D t` created!
Coord2D(x = 2, y = 3) copied
Coord2D(x = 2, y = 3) destroyed
Creating `Coord2D t`…
Coord2D(x = 2, y = 3) moved
`Coord2D t` created!
[…]

Смотрите, что происходит. При первом вызове оператора сложения переменная t инициализируется при помощи copy constructor, так как c1 не является временным объектом. Однако при втором вызове первым аргументом передается временный объект c1 + c2 , и из него переменная t инициализируется уже при помощи move constructor. То есть, фактически std::forward позволил написать процедуру один раз, вместо того, чтобы писать две версии — одну, принимающую первым аргументом lvalue reference, и вторую, работающую с rvalue reference.

Заключение

Заметьте, что думать про всякие move semantics и perfect forwarding нужно только при работе с объектами, держащими в себе много данных, и только если вы часто копируете или присваиваете такие объекты. Это исключительно оптимизация, и без нее все будет совершенно корректно работать (более того, ничего этого не существовало до появления C++11). Пока профайлер не говорит вам , что вы во что-то такое не уперлись, возможно, не стоит заморачиваться. Помните также, что компилятор зачастую может избавляться от лишнего копирования объектов, см return value optimization (RVO) и copy elision .

С другой стороны, теорию понимать стоит независимо от того, упираетсяваш код в копирование и перемещение объектов, или нет. Как минимум, move semantics и иже с ним может использоваться в чужом коде. В частности, он используется в STL, см например метод emplace_back класса std::vector или метод emplace класса std::map . Кроме того, понимание move semantics будет весьма нелишним при использовании умных указателей .

Дополнительные материалы:

Как видите, если сесть и спокойно во всем разобраться, то, вроде как, и ничего сложного. Я, впрочем, далеко не гуру C++. Так что, если вы видите, что мое понимание материала расходится с действительностью, пожалуйста, не поленитесь сообщить об этом в комментариях.

EnglishRussianUkrainian