Работа с ресурсами в C++

Ресурсом в широком смысле можно считать что угодно, требующее "создания" и в последствии "удаления", например: динамическая память, файл, соединение.

В чем сложность

Необходимость вручную удалять ресурс (вызывать функцию удаления) крайне подвержденная ошибкам техника:

  • Программист может создать ресурс и забыть его удалить;
  • В процессе работы с уже выделенным ресурсом может вылететь необработанное исключение, и исполнение не дойдет до функции удаления;
  • Возможно удаление одного и того же ресурса дважды, что в зависимости от используемого API может привести к крашу.

Кроме того, даже если программист не совершает этих ошибок, сама необходимость их предостережения может значительно усложнить читаемость и поддерживаемость кода.

Рассмотрим пример с выделением памяти:

// выделяем память
auto number = new int(1);

// используем память
foo(number);

// освобождаем память
delete number;

Функция foo может выкинуть исключение, и память, выделенная под number, утечет. Это можно исправить, добавив try/catch:

auto number = new int(1);

try {
    foo(number);
} catch(...) {
    // ловим ошибку и продолжаем исполнять текущую функцию, чтобы вызвался delete
}

delete number;

Проблема действительно решена. Попробуем добавить выделение второго ресурса:

auto number = new int(1);
auto anotherNumber = new int(2); // new может выкинуть std::bad_alloc

try {
    foo(number, anotherNumber);
} catch(...) {
    // ловим ошибку и продолжаем исполнять текущую функцию, чтобы вызвался delete
}

delete anotherNumber;
delete number;

Мы все еще ловим исключение из foo, но теперь утечка может произойти и при исключении в момент выделения памяти под anotherNumber - это тоже надо обрабатывать.

auto number = new int(1);
auto anotherNumber = static_cast<int*>(nullptr);

try {
    anotherNumber = new int(2);
    foo(number, anotherNumber);
} catch(...) {
    // ловим ошибку и продолжаем исполнять текущую функцию, чтобы вызвался delete
}

delete anotherNumber;
delete number;

Последняя итерация примера безопасна, но простой вызов функции с 2 ресурсами теперь требует try/catch, а при написании была вероятность забыть обработать ту или иную ошибку. При всем при этом это был простейший пример. В реальности ресурсом может быть, например, мьютекс, который необходимо освобождать как можно раньше, а момент когда его можно освободить зависит от какого либо рантайм условия. В таком случае вся логика усложняется, а это экспоненциально увеличивает вероятность ошибки, усугубляя проблему.

RAII

В C++ проблема работы с ресурсами решается через деструкторы. Более того, это, наверно, основная фича, отличающая язык от чистого C. Через оборачивание ресурса в объект, и привязку времени жизни ресурса к времени жизни объекта, мы передаем головную боль по управлению ресурсом компилятору. Данная техника называется RAII - Resource Acquisition Is Initialization - и по моему субъективному мнению является одной из ключевых в языке.

Применение RAII

Попробуем переписать пример с применением RAII. В языке имеются готовые типы для работы с памятью, но для понимания концепции, сделаем простенькую имплементацию сами.

Для начала нам нужен RAII враппер, оборачивающий сырую память.

// Базовый RAII враппер
struct Memory {
    int* data;

    Memory(int val)
        : data(new int(val)) {}

    ~Memory() {
        delete data;
    }
};

Теперь перепишем наш пример с применением этого типа.

auto number = Memory(1)
auto anotherNumber = Memory(2)

foo(number.data, anotherNumber.data);

В случае если при создании anotherNumber вылетит исключение, деструктор number удалит уже выделенную память. В случае исключения из foo, то же самое произойдет для обеих переменных. Таким образом мы обезопасили себя от ошибок с удалением памяти и увеличили читаемость кода.

Полноценный RAII враппер

Memory уже решает задачу из упрощенного примера, но в текущем виде это не полноценный RAII тип, поскольку он неправильно работает с копированием и перемещением.

auto number = Memory(1);
// скопирует указатель - при уничтожении number & copied память будет освобождена дважды
auto copied = number;
// обнуление указателя number не гарантировано, соответственно так же возможно двойное освобождение
auto moved = std::move(number);

Для решения этих проблем необходимо определить конструкторы копирования и перемещения, или просто запретить их вызов.

// Полноценный RAII враппер
struct Memory {
    int* data;

    Memory(int val)
        : data(new int(val)) {}

    ~Memory() {
        delete data;
    }

    // конструктор копирования
    Memory(const Memory& other)
        : data(new int(*other.data)) {}

    // оператор копирования - обратите внимание, что копируется просто значение
    Memory& operator=(const Memory& other) {
        *data = *other.data;
        return *this;
    }

    // конструктор перемещения - записывает в перемещаемый класс nullptr и забирает реальный указатель
    Memory(Memory&& other)
        : data(std::exchange(other.data, nullptr)) {}

    // оператор перемещения - логика как у конструктора
    Memory& operator=(Memory&& other) {
        data = std::exchange(other.data, nullptr)
        return *this;
    }
};

Rule of 5

Если ваш класс определяет хотя бы что-то из: деструктор, {конструктор, оператор} копирования, {конструктор, оператор} перемещения - это скорее всего значит, что необходимо определить все 5 из этих специальных функций.

Рекоммендации

  • Любой ресурс с семантикой выделения/освобождения должен быть обернут в RAII враппер. Любой ручной вызов функции вида {free,delete,close} красный флаг, требующий внимания.
  • Один класс должен вручную управлять временем жизни не более 1 ресурса.
  • Не стоит писать свой RAII враппер, если есть стандартные альтернативы. Например, для динамической памяти это std::{unique, shared}_ptr, а для файлов - std::fstream.

Еще контент

Подписывайтесь на мой телеграм канал, я выкладываю новости из мира C++ и авторские статьи по типу этой: t.me/krestovii_podhod