Skip to content

Latest commit

 

History

History
216 lines (196 loc) · 11.7 KB

lecture9.md

File metadata and controls

216 lines (196 loc) · 11.7 KB

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);
}

move семантика

Рассмотрим фрагмент кода:

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;
}

std::move

Допустим мы хотим использовать функцию 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 и умные указатели

RAII - концепция, согласно которой захват ресурсов объекта происходит в конструкторе, а освобождение происходит в деструкторе. На такой концепции реализованы умные указатели. Умный указатель - объект, который по структуре похож на реализованный класс, но внутри себя хранит не буффер из объектов, а указатель на один объект.

Говоря более простым языком, умный указатель - указатель, у которого можно не писать каждый раз delete, что бывает очень удобно. Рассмотрим типы умных указателей:

std::unique_ptr

Синтаксис:

#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

Устроен 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 блоки перебираются по очереди и выбирается первый подходящий. Если один блок отработал, то другие не обрабатываются. Исключения можно выбрасывать в виде указателей. Если выбрасывать исключения из конструктора, то объект не будет сконструирован и для него не будет зваться деструктор.