Базовый код с запретом копирования и присваивания
Если вы пока точно не знаете, как будет использоваться класс, лучше всего просто запретить копирование и присваивание. По умолчанию они разрешены и просто копируют все атрибуты класса. Часто это не то, чего вы хотите. Например, тупо копировать какие-то указатели, файловые дескрипторы или мьютексы, являющиеся атрибутами класса, явно плохая идея. Простейший код, в котором копирование и присваивание класса явно запрещены:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
#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; } |
Вывод программы:
|
1 2 3 4 5 |
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 |
Пока что никаких неожиданностей. Стоит отметить, что вместо:
|
1 2 |
Coord2D(Coord2D const &) = delete; void operator=(Coord2D const &) = delete; |
… можно написать:
|
1 2 |
Coord2D(Coord2D const &) = default; void operator=(Coord2D const &) = default; |
… тем самым явно указав на то, что вас устраивают реализации по умолчанию.
Copy constructor
Объявим copy contructor:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
/* … */ 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), несмотря на то, что это другой экземпляр. Вывод программы:
|
1 2 3 4 5 6 7 |
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 оператор:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
/* … */ 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) 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
Перепишем код следующим образом:
|
1 2 3 4 5 6 7 8 9 10 11 |
/* … */ 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; } |
Вывод:
id called
Coord2D(x = 1, y = 2) copied
Coord2D(x = 1, y = 2) destroyed
Hi!
Coord2D(x = -1, y = 2) destroyed
Как видите, мы создаем копию из временного объекта, после чего он сразу уничтожается. Для нас это не проблема, так как объект маленький. Но если бы он содержал в себе большие объемы данных, мы бы создали их полную копию, а затем одну из копий освободили бы. Для решения этой проблемы придумали move constructor:
|
1 2 3 4 5 6 7 8 |
/* … */ Coord2D(Coord2D&& obj) { _x = obj._x; _y = obj._y; std::cout << «Coord2D(x = « << _x << «, y = « << _y << «) moved» << std::endl; } /* … */ |
Вывод:
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, только для присваивания. Например, код:
|
1 2 3 4 5 |
int main() { Coord2D c1(1,2); c1 = Coord2D(4,5); std::cout << «Hi!» << std::endl; } |
… выведет:
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 оператор:
|
1 2 3 4 5 6 7 8 |
/* … */ void operator=(Coord2D&& obj) { _x = obj._x; _y = obj._y; std::cout << «Coord2D(x = « << _x << «, y = « << _y << «) move-assigned» << std::endl; } /* … */ |
Вывод:
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 бывает трудно стриггерить. Например, код:
|
1 2 3 4 |
int main() { Coord2D c1(Coord2D(1,2).setX(5)); std::cout << «Hi!» << std::endl; } |
… выведет:
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. Если переписать код так:
|
1 2 3 4 |
int main() { Coord2D c1(std::move(Coord2D(1,2).setX(5))); std::cout << «Hi!» << std::endl; } |
… программа выведет:
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.
Рассмотрим пример. Определим оператор сложения двух координат:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
/* … */ 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 = 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 будет весьма нелишним при использовании умных указателей.
Дополнительные материалы:
- В книге Effective Modern C++, вся глава 5 посвящена move semantics и perfect forwarding;
- Про RVO и NRVO хорошо написано в блоге Елены Сагалаевой;
Александр Алексеев (“R2AUK”) eax.me/cpp-copying-and-moving

