RAII, move семантика, конструкторы копирования и операторы присваивания, умные указатели, исключения
Предположим, что мы хотим сделать класс Array, у которого будет следующая реализация:
class Array {
public:
...
private:
int* data = nullptr;
size_t size = 0;
};
Мы хотим сделать возможным копирование объекта. Сделать это можно двумя путями. Первый путь - положиться на обычный конструктор копирования и ничего не писать. Тогда он просто скопирует все поля класса(shallow copy), и если мы вызовем у одного объекта деструктор, то второй будет указывать на пустую область памяти и может получиться undefined behavior. Второй - реализовать свой конструктор копирования(deep copy):
Array(const Array& array) {
this->size = array.size;
this->data = new int[size]();
std::copy(array.data, array.data + array.size, data); // копирует массив array.data в data
}
Конструктор копирования вызывается когда мы пишем код в таком стиле:
Array array2 = array1;
Array array2(array1);
array2 = array1; // Здесь не вызывается конструктор копирования
В последнем случае нужно реализовать оператор присваивания:
Array& operator=(const Array& array) {
if (this == &array) { // проверка на то, что объект не равен себе. Нужна для случаев, когда мы хотим сделать array1 = array1
return this*;
}
this->size = array.size;
this->data = new int[size]();
std::copy(array.data, array.data + array.size, data);
}
Рассмотрим фрагмент кода:
Array f(Array array) {
std::cout << "f" << std::endl;
return array; // Бессмысленное копирование, так как array скоро уничтожится после выхода из функции.
}
int main() {
Array array1(10);
Array array2 = f(array1);
}
Как указано в комментариях в коде, происходит бессмысленное копирование. Идея move семантики заключается в том, чтобы избежать этого, передав нужному объекту содержимое array, а значения array сделать неопределенными, но валидными(то есть мы не можем полагаться на его поля, но при этом, например, должен корректно отработать деструктор). Попробуем преобразовать код, указанный выше:
Array(Array&& array) {
this->size = array.size;
this->data = array.data; // воруем значения array
array->data = nullptr; // присваиваем nullptr, чтобы деструктор array не удалил this->data
}
Заметим в коде странный синтаксис - &&(rvalue reference). Он отличается от & тем, что мы не можем передать в него что попало, например не временный объект, который не собирается удаляться. Соотвественно такой вариант конструктора вызовется только от объекта, который скоро удалится. Стоит отметить, что компилятор умный, и он сам понимает, что объект скоро удалится. Теперь при создании array2 будет вызываться новый конструктор.
Напишем move-оператор присваивания:
Array& operator=(Array&& array) {
if (this == &array) {
return *this;
}
this->size = array.size;
this->data = array.data;
array->data = nullptr;
}
Допустим мы хотим использовать функцию f, которая принимает Array&& :
void f(Array&& array) {
std::cout << "f" << std::endl;
}
int main() {
Array array1(10);
f(array1);
return 0;
}
Но мы не можем передавать array1 в нашу функцию, потому что array1 может быть использован после f. Чтобы все-таки передать array1 в функцию, используется функция std::move:
f(std::move(array1));
То есть, грубо говоря std::move() = static_cast<Array&&>(); после такого использования НЕ СТОИТ полагаться на значения array1:
f(std::move(array1));
array1[1] = 0; // компилируется, но так делать не стоит
Стоит отметить, что обхитрить компилятор, создав дополнительную функцию g не получится:
void f(Array&& array) {
std::cout << "f" << std::endl;
}
void g1(Array array) {
f(array); //неправильно, несмотря на то, что array скоро уничтожится
}
void g2(Array array) {
f(std::move(array)); // ok
//do not use array
}
int main() {
Array array1(10);
f(array1);
return 0;
}
Если есть нетривиальный конструктор копирования, оператор присваивания или деструктор, то move-конструктор не генерируется. Чтобы его все-таки сгененировать, нужно прописать:
Array(Array&& array) = default;
RAII - концепция, согласно которой захват ресурсов объекта происходит в конструкторе, а освобождение происходит в деструкторе. На такой концепции реализованы умные указатели. Умный указатель - объект, который по структуре похож на реализованный класс, но внутри себя хранит не буффер из объектов, а указатель на один объект.
Говоря более простым языком, умный указатель - указатель, у которого можно не писать каждый раз delete, что бывает очень удобно. Рассмотрим типы умных указателей:
Синтаксис:
#include <memory>
std::unique_ptr<int> int_ptr(new int);
std::unique_ptr<int> int_ptr1 = std::make_unique<int>(new int(2));
Фишка unique_ptr в том, что у него удален констурктор копирования и оператор присваивания, чтобы два указателя не указывали на один и тот же буффер(иначе один из них мог бы удалиться и удалить данные, которые были привязаны ко второму указателю). То есть unique_ptr единлично владеет памятью, на которую он указан.
Но при этом у unique_ptr работает std::move:
std::unique_ptr<int> int_ptr(new int);
std::unique_ptr<int> int_ptr2(std::move(int_ptr));
Чтобы указывать на один объект из нескольких мест, существует:
Устроен std::shared_ptr следующим образом: помимо данных в указателе хранится переменная use_count, в которой лежит количество использованний данных, которые захвачены std::shared_ptr. Когда std::shared_ptr умирает, он делает --use_count. Когда use_count = 0, освобождается память, которой владел указатель. std::shared_ptr можно копировать только от std::shared_ptr.
Синтаксис:
std::shared_ptr<int> int_ptr1 = std::make_shared<int>(new int(2));
std::shared_ptr<int> int_ptr2 = int_ptr1;
Допустим у нашего Array есть оператор [] и мы хотим как-то обработать ситуации, когда index выходит за границы. Для таких случаев в C++ есть специальный механизм, для того, чтобы сообщать об подобных ошибках - исключения.
Синтаксис:
const int& operator[](size_t index) const {
if (index >= size) {
throw std::exception(); // 1 вариант
throw "Error!"; // 2 вариант(но лучше не использовать)
}
}
Чтобы ловить исключения, существует конструкция try-catch:
Пример:
try {
Array array(3);
array[5] = 10;
} catch(...) {
std::cout << "Error!";
}
Сначала выполняется блок try. Если все ок и исключения не выбрасываются, он идет дальше, иначе он переходит в блок catch, в котором обрабатывает ошибку и идет дальше. Вместо ... можно обрабатывать свои исключения. Например:
try {
Array array(3);
array[5] = 10;
} catch(const exception &e) {
std::cout << "Error: " << e.what(); // e.what() возвращает информацию об ошибкею
}
Исключения образуют иерархию, то есть различные виды exception наследуются друг от друга. Например std::out_of_range наследуется от std::exception. Если в операторе [] выбрасывать std::out_of_range, то в коде выше это исключение так же будет обрабатываться в блоке catch. Блоков catch может быть несколько, например:
try {
Array array(3);
array[5] = 10;
} catch(std::out_of_range &e) {
std::cout << "Out of range: " << e.what();
} catch(const exception &e) {
std::cout << "Error: " << e.what();
}
catch блоки перебираются по очереди и выбирается первый подходящий. Если один блок отработал, то другие не обрабатываются. Исключения можно выбрасывать в виде указателей. Если выбрасывать исключения из конструктора, то объект не будет сконструирован и для него не будет зваться деструктор.