Функция открывает файл, берёт мьютекс, начинает транзакцию и выделяет буфер. Каждое действие требует парного завершения. Двадцать строк ниже программист пишет 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 без изменения поведения

Порядок перевода проекта на обёртки обычно такой:

  1. составить реестр ресурсов: файлы, сокеты, блокировки, транзакции, дескрипторы внешних API;
  2. для каждого ресурса найти точки приобретения и освобождения и проверить их парность;
  3. переиспользовать готовые обёртки стандартной библиотеки, свои писать только под специфичные API;
  4. у каждой обёртки запретить копирование или определить его осознанно, а перемещение сделать переносом владения;
  5. покрыть обёртку тестом с исключением, брошенным между захватом и освобождением;
  6. прогнать санитайзер до и после перевода и сравнить отчёты;
  7. удалить из прикладного кода явные вызовы закрытия, оставив их там, где нужен код ошибки.

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

Где явное закрытие всё же нужно и как его совмещают с деструктором

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