Рассмотрим, как строятся взаимоотношения между объектами в 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])
- Поддержать автора за работу над уроками