Рассмотрим, как строятся взаимоотношения между объектами в C++. Простейший класс:
class Foo {
public:
int bar = 0;
};
Как и почему работает следующий код?
Foo f1;
Foo f2(f1);
Foo f3 = f2;
Foo f4 = Foo();
Foo f5 = Foo(f4);
f5 = f4;
Когда мы создаём класс, компилятор автоматически добавляет в него некоторые инструменты "по умолчанию", т.е. мы их не писали - но они там есть. Давайте их рассмотрим.
"По умолчанию" #
Конструктор #
Про него было рассказано в уроке про классы. Конструктор по умолчанию (default constructor) просто создаёт объект со значениями по умолчанию, т.е. вызов Foo()
"вернёт" новый безымянный объект, а Foo f1
создаст новый объект f1
, у которого будет автоматически вызван конструктор по умолчанию.
Конструктор по умолчанию Foo()
можно переопределить:
class Foo {
public:
Foo() {
bar = 123;
}
int bar = 0;
};
Теперь у Foo f
значение f.bar
будет равно 123
, т.к. при создании вызовется написанный нами код:
Foo f; // вызван переопределённый нами конструктор по умолчанию
f.bar == 123; // верно
Деструктор #
Тоже вам знаком - вызывается при уничтожении объекта и тоже существует в классе по умолчанию - ~Foo()
, его можно переопределить:
class Foo {
public:
~Foo() {
// "код"
}
int bar = 0;
};
{
Foo f;
} // здесь вызовется "код"
Конструктор копирования #
Помимо конструктора по умолчанию создаётся конструктор копирования (copy constructor), он позволяет писать конструкции вида Foo f2(f1)
или Foo(f1)
. Этот конструктор создаёт копию объекта, т.е. буквально копирует значения его переменных:
Foo f1; // конструктор по умолчанию
f1.bar = 456;
Foo f2(f1); // конструктор копирования
f2.bar == 456; // верно
Конструктор копирования Foo(const Foo&)
можно переопределить:
class Foo {
public:
Foo(const Foo& other) {
// здесь other - копируемый объект
// а bar - переменная нашего объекта
bar = other.bar;
}
int bar = 0;
};
Выше я сделал то же самое, что по сути делает конструктор копирования по умолчанию - скопировал переменную "себе". Но здесь может быть и другой код.
Инциализация #
Формально, с точки зрения стандарта C++, вызов конструктора копирования происходит также при инициализации объекта:
Foo f1 = Foo(); // создан новый объект и скопирован в f1
Foo f2 = f1; // f1 скопирован в f2
Foo f3 = Foo(f2); // создана копия f2 и затем скопирована в f3
На деле компилятор в целях оптимизации часто применяет copy elision или RVO - вызов конструктора при инициализации не происходит, объект просто создаётся "на месте"
Оператор копирования #
При присваивании одного объекта другому он ожидаемо копируется в него при помощи оператора присваивания копированием (copy assignment operator) - как и в случае с конструктором копирования, все члены одного объекта просто копируются в члены другого:
Foo f1, f2;
f1.bar = 123;
f2 = f1;
f2.bar == 123; // верно
Оператор возвращает ссылку на сам объект - Foo&
, т.е. вот такой код будет работать как ожидается:
f1 = f2 = Foo();
Справа налево:
- Вызов конструктора по умолчанию
Foo()
создаёт новый объект - Он копируется в
f2
при помощи оператора присваивания и возвращаетсяf2
f2
копируется вf1
при помощи оператора присваивания
Оператор присваивания копированием Foo& operator=(const Foo&)
можно переопределить:
class Foo {
public:
Foo& operator=(const Foo& other) {
bar = other.bar;
return *this;
}
int bar = 0;
};
Здесь я также написал то, что было по умолчанию - скопировал переменную.
Неявный вызов конструктора #
Если мы создадим какой-нибудь другой конструктор, то он может быть вызван неявно, исходя из аргумента. Например добавим конструктор, который инициализирует свойство bar
указанным значением типа int
:
class Foo {
public:
Foo() {}
Foo(int v) : bar(v) {}
int bar = 0;
};
Теперь будут работать следующие конструкции:
Foo f1(123); // обычный вызов конструктора Foo(int)
Foo f2 = Foo(123); // создан объект и скопирован в f2
Foo f3 = 123; // из 123 будет создан объект Foo(123) и скопирован (copy constructor) в f3
f3 = 123; // из 123 будет создан объект Foo(123) и скопирован (copy assignment) в f3
// bar везде равно 123
То есть число int
будет неявно преобразовано к Foo
, т.к. Foo
имеет такой конструктор. Примеры выше могут быть оптимизированы компилятором, но формально работают именно так.
rvalue #
В C++11 (справедливо для AVR Arduino, ESP8266, ESP32) появилось понятие rvalue (right value) - это значение, которое не имеет имени, адреса в памяти и не может быть ссылкой, это временное значение, которое находится "справа" от оператора присваивания =
и будет выгружено из памяти после выполнения операции присваивания (если это объект - будет вызван его деструктор):
int x = 123; // 123 - rvalue
Foo f = Foo(); // формально Foo() создаёт rvalue-объект
Также rvalue будет являться результат выполнения функции:
Foo getFoo() {
Foo f;
return f;
}
f = getFoo(); // getFoo() возвращает временный rvalue объект
Для работы с rvalue есть отдельный синтаксис - такой объект передаётся по &&
, т.е. это как "двойная ссылка". Если существует пара перегруженных функций, которые принимают объект по Foo&
и Foo&&
, то при передаче rvalue объекта будет вызвана версия с &&
:
void func(const Foo& val) {} // #1
void func(Foo&& rval) {} // #2
Foo foo;
func(foo); // вызов #1
func(Foo()); // вызов #2
Обратите внимание: ссылка указывается как const
(функция не может изменить объект по ней), а для rvalue - просто &&
, неконстантно - функция сможет менять объект
Объект можно принудительно "кастовать в rvalue" (Foo&&)
для вызова соответствующей функции. На сам объект каст никак не повлияет, он не уничтожится и никуда не денется - это просто указание компилятору, какую функцию вызвать:
Foo foo;
func(foo); // вызов #1
func((Foo&&)foo); // вызов #2
Конструктор перемещения #
Здесь всё просто - это как конструктор копирования, только принимает rval: Foo(Foo&&)
. По умолчанию ведёт себя точно так же, как конструктор копирования: никого никуда не перемещает, а точно так же копирует в текущий объект. Но его поведение тоже можно переопределить:
class Foo {
public:
Foo(Foo&& other) noexcept {
bar = other.bar;
}
int bar = 0;
};
У конструкторов и операторов с rvalue указывается спецификатор noexcept
, чтобы компилятор мог оптимизировать перемещение сущностей, которые умеют перемещаться (например стандартных контейнеров из стандартной библиотеки STL). Даже если в классе не используются подобные контейнеры - лучше всё равно указывать noexcept
.
Оператор перемещения #
По умолчанию - то же самое, что оператор присваивания, но для rvalue - Foo& operator=(Foo&&)
, по умолчанию копирует объект. Можно переопределить:
class Foo {
public:
Foo& operator=(Foo& other) noexcept {
bar = other.bar;
return *this;
}
int bar = 0;
};
Точно так же указываем noexcept
.
Всё вместе #
Итого у класса есть целый набор стандартных инструментов для базового взаимодействия между своими объектами:
class Foo {
public:
Foo() {} // default constructor
~Foo() {} // destructor
Foo(const Foo& other) {} // copy constructor
Foo(Foo&& rval) noexcept {} // move constructor
Foo& operator=(const Foo& other) {} // copy assignment
Foo& operator=(Foo&& rval) noexcept {} // move assignment
};
Переопределение #
- При добавлении нового конструктора мы теряем конструктор по умолчанию, если он нужен - его нужно объявить явно
- При переопределении нужно быть очень внимательным и не пропустить члены, которые нужно скопировать или переместить - они останутся нетронутыми, т.е. переопределяя оператор или конструктор, мы полностью теряем его "стандартное" поведение
class Foo {
public:
Foo() {} // по умолчанию, создан явно
Foo(const Foo& other) {
a = other.a;
// забыли про b
}
int a = 0;
int b = 0;
};
Foo f1; // по умолчанию
f1.a = 123;
f1.b = 456;
Foo f2(f1);
//f2.a == 123
//f2.b == 0 !
Правило 0 #
Вы не обязаны создавать конструкторы и операторы копирования и перемещения - они уже существуют по умолчанию.
Правило 3 и 5 #
Правило трёх (Rule of Three) - если в классе вам нужно переопределить деструктор, конструктор копирования или оператор копирования присваиванием, то скорее всего нужно будет переопределить все 3 из перечисленных, чтобы программа работала корректно во всех случаях.
Правило пяти (Rule of Five) - то же самое, но включает в себя дополнительно конструктор и оператор перемещения, т.е. для C++11 и выше.

Управление данными #
Зачем же нужен этот набор операторов и конструкторов с возможностью переопределения? Они могут применяться в разных сценариях взаимодействия объектов, но самый частый - если объект динамически выделяет память - в этом случае без переопределения операторов в принципе не получится написать корректно работающий класс!
Рассмотрим самый простой и наглядный пример - класс динамического массива типа int
. Нам понадобится как минимум:
- Переменная-указатель на
int
для записи адреса выделенной памяти:int* _buf
- Конструктор с указанием длины массива - в нём выделим память и запишем в указатель:
_buf = new int[len]
- Деструктор - в нём освободим выделенную память:
delete[] _buf
- Для удобства и тестов:
- Переменная для хранения длины массива в количестве ячеек:
size_t _len
- Метод получения текущей длины:
return _len
- Оператор для доступа к объекту как к указателю и через
[]
:return _buf
- Переменная для хранения длины массива в количестве ячеек:
class Array {
public:
Array(int len) {
_buf = new int[len];
if (_buf) _len = len; // если успешно выделено. Иначе останется 0
}
~Array() {
delete[] _buf;
}
operator int*() {
return _buf;
}
size_t length() {
return _len;
}
private:
int* _buf = nullptr;
size_t _len = 0;
};
Всё работает, можно пользоваться...
Array arr(10); // 10 ячеек
arr[0] = 123;
arr[1] = 456;
arr[0]; // == 123
arr[1]; // == 456
arr.length(); // == 10
... ровно до тех пор, пока не начнётся взаимодействие между объектами. Рассмотрим вот такую ситуацию:
Array arr1(10);
Array arr2(20);
arr1 = arr2; // <- здесь
Сработает оператор присваивания по умолчанию и просто скопирует значения переменных из arr2
в arr1
. Это означает, что:
- Указатель
arr1._buf
теперь равенarr2._buf
и указывает на те же данные в памяти, то есть оба объекта теперь управляют одним и тем же массивом - Старое значение
arr1._buf
утеряно, а значит освободить память теперь невозможно - появилась утечка памяти - Когда объекты выйдут из своей области существования, то сначала удалится
arr2
, его деструктор освободит память. Затем удалитсяarr1
и его деструктор будет освобождать память по указателю, который уже был освобождён (тот же адрес, что у второго объекта) - программа сломается
Дальше - больше:
arr1 = Array(10);
Здесь будет создан новый временный объект массива и присвоен (скопирован) в arr1
:
- Адрес предыдущего выделенного блока
arr1
будет утерян - утечка памяти - После выполнения операции временный массив
Array(10)
будет удалён, т.е. его деструктор освободит выделенную память. Вarr1
останется указатель на эту память, т.е. запись и чтение будет происходить уже вне выделенной памяти - программа сломается - При удалении
arr1
будет освобождена память по указателю, который уже не указывает на выделенную память - программа сломается
Чтобы всё это работало как надо, наш класс массива должен научиться взаимодействовать со своими объектами и правильно манипулировать их данными, для этого понадобится переопределить конструкторы и операторы копирования и перемещения.
Всё вручную #
Копирование #
Первым делом добавим конструктор копирования, который выделит необходимую память и скопирует в неё данные из второго объекта. Он может выглядеть так:
Array(const Array& other) : Array(other._len) { // #1
memcpy(_buf, other._buf, _len * sizeof(int)); // #2
}
- Вызываем конструктор с длиной второго массива
other._len
, чтобы наш объект выделил память и запомнил длину - Копируем память себе в
_buf
изother._buf
размером_len * sizeof(int)
байт. Если на предыдущем шаге память выделить не удалось - размер будет ноль, данные не скопируются - безопасно
Далее - оператор копирования:
Array& operator=(const Array& other) {
if (this == &other) return *this; // #1
delete[] _buf; // #2
_buf = new int[other._len]; // #3
_len = _buf ? other._len : 0; // #4
memcpy(_buf, other._buf, _len * sizeof(int)); // #5
return *this;
}
- Если это наш объект (операция
arr = arr
) - выходим - Освобождаем свою память
- Выделяем новую размером как у второго массива
- Если выделено успешно - записываем новую длину, иначе длина
0
- Копируем данные. Если память выделить не удалось - копирования не будет, безопасно
Перемещение #
Что касается перемещения: если объект является rvalue, значит он будет удалён после этой операции, значит его данные ему "не нужны" - их можно не копировать (что занимает время), а переместить - записать себе значение его указателя, а у rvalue-объекта этот указатель обнулить в nullptr
, чтобы он не освободил его в своём деструкторе. Про длину тоже не забываем:
Array(Array&& other) noexcept {
_len = other._len;
other._len = 0;
_buf = other._buf;
other._buf = nullptr;
}
Array& operator=(Array&& other) noexcept {
if (this == &other) return *this; // такая же защита
delete[] _buf; // освобождаём свой буфер
_len = other._len;
other._len = 0;
_buf = other._buf;
other._buf = nullptr;
return *this;
}
Ну вот, теперь наш класс полностью укомплектован и умеет копироваться и перемещаться, теперь ему не страшны никакие операции между объектами!
Array arr1(10);
Array arr2 = Array(20);
// arr1.length() == 10, arr2.length() == 20
arr1 = arr2; // копирование
// arr1.length() == arr2.length() == 20
arr1 = (Array&&)arr2; // имитируем перемещение
// arr1.length() == 10, arr2.length() == 0
// тут arr2 по сути уже удалён
class Array
class Array {
public:
Array(int len) {
_buf = new int[len];
if (_buf) _len = len;
}
~Array() {
delete[] _buf;
}
Array(const Array& other) : Array(other._len) {
memcpy(_buf, other._buf, _len * sizeof(int));
}
Array& operator=(const Array& other) {
if (this == &other) return *this;
delete[] _buf;
_buf = new int[other._len];
_len = _buf ? other._len : 0;
memcpy(_buf, other._buf, _len * sizeof(int));
return *this;
}
Array(Array&& other) noexcept {
_len = other._len;
other._len = 0;
_buf = other._buf;
other._buf = nullptr;
}
Array& operator=(Array&& other) noexcept {
delete[] _buf;
_len = other._len;
other._len = 0;
_buf = other._buf;
other._buf = nullptr;
return *this;
}
operator int*() {
return _buf;
}
size_t length() {
return _len;
}
private:
int* _buf = nullptr;
size_t _len = 0;
};
Всё хорошо, но можно заметить некоторое дублирование кода и лишние конструкции - можно оптимизировать.
Идиома copy and swap #
Данный подход скопировать и поменять "как слышится, так и пишется" - нужно буквально скопировать объект и поменять его местами со своим. Для реализации подхода нам понадобятся конструкторы копирования и перемещения - мы их уже написали выше. Конструктор перемещения можно представить чуть в другом виде - не "обнулять" второй объект, а почленно поменять его со текущим, в котором переменные по умолчанию равны нулю. Вынесем эту операцию в отдельный метод swap
:
// поменять значения переменных текущего объекта с другим
void swap(Array& other) {
size_t len_temp = other._len;
other._len = _len;
_len = len_temp;
int* buf_temp = other._buf;
other._buf = _buf;
_buf = buf_temp;
}
И вот наши конструкторы:
Array(const Array& other) : Array(other._len) {
memcpy(_buf, other._buf, _len * sizeof(int));
}
Array(Array&& other) noexcept {
swap(other);
}
Конструктор перемещения теперь работает так: нам дают rvalue и мы меняемся с ним местами, теперь у нас указатель на его выделенные данные, а у него - nullptr
, который он проигнорирует при удалении в своём деструкторе.
Теперь самое интересное - операторы копирования и перемещения нам больше не нужны. Вместо них теперь будет оператор присваивания объекта по значению, т.е. уже не по ссылке и не по &&
:
Array& operator=(Array other) {
swap(other);
return *this;
}
Здесь и происходит "copy and swap": оператор принимает объект по значению, то есть будет вызван конструктор - копирования или перемещения, с этим компилятор разберётся сам. При копировании получим копию, при перемещении - по сути тоже копию, отобранную у rvalue. А дальше swap - меняемся с ней местами. Если у нашего объекта была выделена память - она освободится в деструкторе other
, и всё!
Рассмотрим поэтапно работу следующей строки:
arr = Array(10);
Компилятор может оптимизировать некоторые этапы, но формально происходит следующее:
- Код
Array(10)
создаст новый объект через конструктор с размером. Это временный безымянный объект, условно назовём егоtemp
- Попадаем в оператор
=(Array other)
, здесьother
создаётся через конструкторArray other(temp)
, в данном случае - перемещения, т.к.temp
является rvalue:temp
перемещается вother
- Теперь меняемся местами с
other
- получаем новый пустой массив, аother
получает что было у нас и освобождает в своём деструкторе
class Array
class Array {
public:
Array(int len) {
_buf = new int[len];
if (_buf) _len = len;
}
~Array() {
delete[] _buf;
}
Array(const Array& other) : Array(other._len) {
memcpy(_buf, other._buf, _len * sizeof(int));
}
Array(Array&& other) noexcept {
swap(other);
}
Array& operator=(Array other) {
swap(other);
return *this;
}
void swap(Array& other) {
size_t len_temp = other._len;
other._len = _len;
_len = len_temp;
int* buf_temp = other._buf;
other._buf = _buf;
_buf = buf_temp;
}
operator int*() {
return _buf;
}
size_t length() {
return _len;
}
private:
int* _buf = nullptr;
size_t _len = 0;
};
Вложенные объекты и наследование #
Итак, у нас есть умный объект, который умеет распоряжаться своими данными. Что будет, если вложить его в другой класс?
class Foo {
public:
Foo(int s1, int s2) : arr1(s1), arr2(s2) {}
Array arr1, arr2;
};
Всё будет работать как ожидается - объекты класса Foo
могут присваиваться друг к другу и создаваться в конструкторе, а вложенные в них экземпляры Array
будут вести себя подобающе - если Foo
копируется, то и они копируются, если Foo
перемещается, то и члены Array
становятся Array&&
.
При наследовании тоже всё ожидаемо и происходит автоматически: если Foo
копируется, то входящий в его состав Array
- тоже.
class Foo : public Array {
};
Я запрещаю вам copy-ать #
Операторы кстати можно "удалять" - указывать компилятору, что их нельзя вызвать. Например, запретить копировать объект - можно будет только перемещать:
class Foo {
public:
Foo() {}
~Foo() {}
Foo(const Foo& other) = delete;
Foo(Foo&& rval) noexcept {}
Foo& operator=(const Foo& other) = delete;
Foo& operator=(Foo&& rval) noexcept {}
};
Foo f1;
Foo f2(f1); // ошибка
f1 = f2; // ошибка
Готовые инструменты #
В стандартной библиотеке C++ std
есть готовые инструменты (динамические массивы и прочие похожие), которые устроены внутри подобным образом, при их наличии стоит отдавать предпочтение им (например в компиляторе AVR Arduino, avr-gcc, их нет). Но даже при их использовании нужно понимать, как они работают и что происходит в программе на уровне синтаксиса языка. Например std::move(obj)
позволяет "переместить" объект вместо копирования: foo1 = std::move(foo2)
, а на самом деле просто кастует foo2
к rvalue, как мы делали выше, т.е. условно foo1 = (Foo&&)foo2
.

Полезные страницы #
- Набор GyverKIT – наш большой стартовый набор Arduino, продаётся в России
- Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress
- Обратная связь – сообщить об ошибке в уроке или предложить дополнение по тексту ([email protected])
- Поддержать автора за работу над уроками