Работа с ресурсами в 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