Функция открывает файл, берёт мьютекс, начинает транзакцию и выделяет буфер. Каждое действие требует парного завершения. Двадцать строк ниже программист пишет fclose, unlock и commit, и на десять лет всё работает. Потом в середину функции добавляют ранний возврат, и один из ресурсов повисает в воздухе. RAII убирает целую категорию таких ошибок: ресурс живёт ровно столько, сколько живёт владеющий им объект, и освобождается в деструкторе при любом выходе из области видимости. Принцип отрабатывали в конце восьмидесятых Бьёрн Страуструп и Эндрю Кёниг, сам термин придумал Страуструп. Ниже механика принципа, его цена, границы и честный разбор случаев, когда явный вызов метода освобождения всё же нужен.
Как RAII связывает время жизни ресурса с временем жизни объекта
Расшифровка аббревиатуры, Resource Acquisition Is Initialization, звучит загадочно: приобретение ресурса это инициализация. Смысл в связке. Класс захватывает ресурс в конструкторе и возвращает его в деструкторе. Объект родился, ресурс получен. Объект разрушен, ресурс освобождён. Никакого отдельного метода close, чьё выполнение надо помнить.
Опора принципа это детерминированное разрушение. Локальные переменные C++ разрушаются в предсказуемый момент: при выходе из области видимости, в порядке, обратном созданию. Исключение не отменяет порядок, а запускает раскрутку стека: по пути наверх компилятор вызывает деструкторы всех созданных объектов. Ресурс не утекает даже там, где программист не написал ни строчки обработки ошибок.
class FileGuard {
std::FILE* f_;
public:
explicit FileGuard(std::FILE* f) : f_{f} {}
~FileGuard() { if (f_) std::fclose(f_); }
FileGuard(const FileGuard&) = delete;
FileGuard& operator=(const FileGuard&) = delete;
};
Тот же порядок работает внутри объекта. Члены класса разрушаются в обратном порядке объявления, поэтому обёртки безопасны в конструкторах: если третий член не смог инициализироваться и бросил, у первых двух деструкторы отработают, и их ресурсы вернутся. Автору класса не нужно вручную откатывать уже готовое.
Проверить обещание легко:
void load(Config& out) {
FileGuard g{std::fopen("cfg.ini", "r")};
if (!g.get()) throw std::runtime_error("файл конфигурации не открыт");
parse(g.get(), out);
}
Внутри функции нет ни одного catch, но файл закроется при любом исходе parse: раскрутка стека вызовет деструктор g. Тест из трёх строк доказывает это сейчас, а конструкция класса гарантирует навсегда.
У принципа есть и другие имена: CADRe, Constructor Acquires и Destructor Releases, и SBRM, управление по области видимости. Суть не в аббревиатуре. Деструктор это единственная функция, которую язык вызывает сам, и вся дисциплина стоит на этом автоматическом вызове.
Где ручное освобождение ломается даже у дисциплинированного программиста
Функция с ручным закрытием копит повторы на каждом пути выхода:
void save(const char* path) {
FILE* f = std::fopen(path, "w");
if (!f) return;
if (!writeHeader(f)) { std::fclose(f); return; }
if (!writeBody(f)) { std::fclose(f); return; }
std::fclose(f);
}
Три вызова fclose на одну функцию. Появится четвёртый путь возврата, и кто-нибудь забудет про один. Бросок исключения между открытием и закрытием обходит все строки с fclose, и дескриптор течёт. Механическая защита через try и catch раздувает код: на каждый ресурс свой catch со своей уборкой, и через год в этих catch-блоках никто не разбирается.
Есть и вторая половина беды: двойное освобождение. Одна ветка закрывает ресурс, вторая делает то же самое, и в дескрипторах с внутренним состоянием это уже не утечка, а повреждение. Ручное управление требует держать в голове оба сценария, забывание и повтор, и цена ошибки растёт с размером команды.
Сравнение с другими языками здесь показательно. В Java есть блок try-with-resources, в C# конструкция using, в Go отложенный defer, в Python менеджер with. Все они требуют, чтобы программист вспомнил о закрытии и сам написал конструкцию. RAII работает молчу: переменная объявлена, остальное язык делает сам. Забыть нельзя, потому что помнить нечего.
И последний слой выгоды, невидимый с первого взгляда: композиция. Функция, получившая файл из другой функции, не знает, надо ли закрывать. Обёртка отвечает типом: передали FileGuard по значению, закрытие переехало вместе с ним. Владение читается из сигнатуры, а не из комментариев.
Почему деструкторы не бросают исключений и что бывает при нарушении
У деструктора жёсткое ограничение: с C++11 он неявно помечен noexcept. Причина механическая. Деструктор вызывается и в нормальной работе, и при раскрутке стека после исключения. Если во время раскрутки из деструктора вылетит второе исключение, восстановить состояние уже некому, и процесс завершится через std::terminate. Один бросок превращает обработку ошибки в аварийный останов всей программы.
Отсюда практические правила. Деструктор не бросает никогда. Ошибку освобождения он либо молча проглатывает, либо тихо записывает в лог. Если закрытие может отказать, а отказ надо обработать, у класса появляется явный метод, а деструктор становится страховкой: вызвал close явно, получил код ошибки, деструктор в конце докроет недозакрытое.
Идиоматическая пара хранит состояние в поле. Явный метод сбрасывает дескриптор в пустое значение, деструктор проверяет поле и закрывает только непустой дескриптор. Повторное освобождение исключается конструктивно, без флагов и уговоров. Стандарт формально позволяет пометить деструктор noexcept(false), но в боевом коде это приглашение к останову процесса.
Классу с ресурсом приходится определять и остальные операции из правила пяти: копирование, присваивание, перемещение и его присваивание, деструктор. Проще всего запретить копирование, как в FileGuard выше, и разрешить перемещение, переносящее владение. Соглашение: перемещённый объект остаётся пустым, и его деструктор ничего не делает.
Мьютексы транзакции и потоки как RAII за пределами памяти
Память только первый клиент принципа. Блокировка живёт по тем же правилам: захват в конструкторе, разблокировка в деструкторе.
std::mutex m;
std::map<std::string, int> counters;
void bump(const std::string& key) {
std::lock_guard lock(m);
++counters[key];
}
std::lock_guard существует с C++11, вывод аргумента шаблона по типу мьютекса с C++17. Любой ранний возврат и любое исключение внутри критической секции не оставят мьютекс занятым. Взаимные блокировки RAII, разумеется, не лечит, но класс ошибок "забыл разблокировать" исчезает из баг-трекера полностью.
Транзакция хранилища оборачивается так же: конструктор выполняет begin, деструктор делает откат, если не было фиксации, а явный commit завершает успех. Откат при исключении происходит сам, и это ровно то поведение, какое от транзакции и ждут.
Плохо в старом коде выглядят потоки. Деструктор std::thread при работающем потоке вызывает std::terminate: библиотека отказывается выбирать за программиста между ожиданием и отсоединением. Класс std::jthread из C++20 вежливее: он запрашивает кооперативную остановку через stop_token и присоединяется к потоку в деструкторе.
std::jthread worker([](std::stop_token st) {
while (!st.stop_requested()) {
poll_queue();
}
});
Экзотические ресурсы укладываются в тот же шаблон. Обёртка с деструктором пишется один раз:
template <typename Handle, auto Release>
class UniqueHandle {
Handle h_;
public:
explicit UniqueHandle(Handle h = Handle{}) : h_{h} {}
~UniqueHandle() { if (h_) Release(h_); }
UniqueHandle(UniqueHandle&& other) noexcept
: h_{std::exchange(other.h_, Handle{})} {}
UniqueHandle(const UniqueHandle&) = delete;
Handle get() const noexcept { return h_; }
};
Нетиповой параметр вида auto Release доступен с C++17, и такой каркас покрывает дескрипторы графических API, сокеты, дескрипторы операционной системы и любые библиотеки, где освобождение выражается функцией с одним аргументом.
Что дают обёртки в больших системах и как это выглядит в отчётах санитайзера
Первое измеримое следствие перехода это отчёты об утечках. AddressSanitizer при сборке с флагом -fsanitize=address перечисляет утёкшие выделения к моменту завершения процесса, Valgrind делает то же своей проверкой утечек. База, где ресурсы держатся обёртками, даёт в отчёте пустой список, и это не везение: для утечки нужно сначала потерять объект, а потерять объект в C++ заметно труднее, чем забыть вызвать close.
Второе следствие это предсказуемость. Освобождение происходит в известный момент, а не когда-нибудь после работы сборщика мусора. Для систем с жёсткими требованиями ко времени отклика это не деталь, а само требование: закрыть дескриптор можно точно до начала итерации цикла управления, без фазы ожидания неспешной уборки.
Третье следствие проявляется при композиции. Контейнер стандартной библиотеки владеет элементами и разрушает их в своём деструкторе. Если элементы сами держат ресурсы, раскрутка срабатывает каскадом: исключение в середине заполнения вектора разрушает уже созданные элементы, и каждый вернёт своё. Автоматическое освобождение масштабируется, ручное нет: количество путей выхода растёт быстрее, чем способность ревьюера их проверять.
Есть и неудобная правда про глобальные объекты. Обёртки в статической области видимости разрушаются при завершении программы, порядок внутри единицы трансляции обратен построению, а порядок между единицами не определён. Поэтому держать в глобальных обёртках ресурсы, зависящие от других глобальных ресурсов, не стоит: пул, разрушающийся раньше соединений, это классическая авария при остановке. Локальные объекты и члены классов такой проблемы не имеют.
Порядок оборачивания ресурсов проекта в RAII без изменения поведения
Порядок перевода проекта на обёртки обычно такой:
- составить реестр ресурсов: файлы, сокеты, блокировки, транзакции, дескрипторы внешних API;
- для каждого ресурса найти точки приобретения и освобождения и проверить их парность;
- переиспользовать готовые обёртки стандартной библиотеки, свои писать только под специфичные API;
- у каждой обёртки запретить копирование или определить его осознанно, а перемещение сделать переносом владения;
- покрыть обёртку тестом с исключением, брошенным между захватом и освобождением;
- прогнать санитайзер до и после перевода и сравнить отчёты;
- удалить из прикладного кода явные вызовы закрытия, оставив их там, где нужен код ошибки.
Скупой на сюрпризы путь это стандартные обёртки, свои писать только для экзотики. По-настоящему уникальный ресурс встречается редко, и в девяти случаях из десяти в зависимостях проекта уже лежит обёртка, которую не заметили.
Где явное закрытие всё же нужно и как его совмещают с деструктором
У деструктора есть слепая зона: он не возвращает код ошибки. Закрытие сетевого соединения может отказать, сброс буфера на диск может отказать, и молча проглотить отказ нельзя. Отсюда честные сценарии для явных методов: close с возвратом результата, flush по расписанию, возврат объекта в пул вместо разрушения. Явный вызов не отменяет RAII, он встраивается внутрь: метод сбрасывает поле, деструктор смотрит на поле и молча завершает незакрытое.
class Connection {
handle_t h_;
public:
std::error_code close() noexcept; // явная попытка с кодом ошибки
~Connection() {
if (h_ != invalid) close(); // страховка, код ошибки не нужен
}
};
Для разовых действий в функции существует приём scope guard: лямбда с освобождением регистрируется на выходе из области. Библиотека GSL даёт утилиту gsl::finally, в самом стандарте такого класса пока нет. Приём честный, но им не стоит злоупотреблять: постоянная уборка через лямбды это то же ручное освобождение, только переодетое.
Правило Core Guidelines R.1 прямо требует не управлять ресурсами вручную, правило E.6 добавляет: использовать RAII, чтобы утечки были невозможны. Ключевое слово невозможны. Дисциплина ревью такой гарантии не даёт, потому что ревьюер человек и он устаёт. Деструктор вызывает машина, и машина не забывает.
Итоговая граница выглядит так: жизненным циклом ресурса управляет обёртка, ошибками освобождения управляет человек через явный вызов, а деструктор остаётся страховкой на случай человеческой ошибки. Именно связка из трёх ролей, а не запрет на слово close, отличает зрелый код от легаси, в котором каждая функция заканчивается списком уборки на пять строк.