RAII: ресурс = время жизни объекта

Вопрос с собеседования: что выведет этот код?

$$ $$

#include <iostream>

class Logger {
public:
    Logger(const std::string& name) : name_(name) {
        std::cout << "Открыл: " << name_ << "\n";
    }
    ~Logger() {
        std::cout << "Закрыл: " << name_ << "\n";
    }
private:
    std::string name_;
};

void doWork() {
    Logger log("файл.txt");
    std::cout << "Работаем...\n";
    throw std::runtime_error("что-то сломалось");
}

int main() {
    try {
        doWork();
    } catch (...) {
        std::cout << "Поймали исключение\n";
    }
}

Вывод:

Открыл: файл.txt
Работаем...
Закрыл: файл.txt
Поймали исключение

Многие ожидают, что строка «Закрыл: файл.txt» не появится — ведь функция вылетела с исключением, а не дошла до конца нормально. Но она появляется. И это не случайность, а суть идиомы, которая держит на себе весь современный C++.

RAII расшифровывается как Resource Acquisition Is Initialization — «получение ресурса есть инициализация». Звучит громоздко, а идея простая: ты привязываешь время жизни ресурса (память, файл, сетевое соединение, мьютекс — что угодно, что нужно потом «освободить») к времени жизни обычного объекта на стеке. Ресурс захватывается в конструкторе. Ресурс освобождается в деструкторе. А деструктор объекта на стеке C++ вызывает всегда, когда объект выходит из области видимости — неважно, дошла функция до конца, вернулась через return раньше, или её прервало исключение.

В примере выше объект log создаётся на стеке функции doWork. Когда внутри функции бросается исключение, начинается процесс, который называется stack unwinding — «раскрутка стека». Программа не просто прыгает к обработчику catch, она аккуратно проходит назад по всем объектам, которые успели создаться на стеке, и вызывает их деструкторы. Поэтому Logger корректно печатает «Закрыл» ещё до того, как управление доходит до catch.

Почему это так важно именно для C++? Потому что в этом языке нет сборщика мусора. В Java или Python за тебя решает GC: когда-то, не факт что сразу, он найдёт неиспользуемый объект и освободит память. C++ на такую роскошь не рассчитывает — здесь ты обязан сам гарантировать, что каждый malloc получит free, каждый new — delete, каждый open() — close(). Раньше это делали руками, и постоянно забывали закрыть файл в одной из веток кода или пропускали delete в obscure edge case с исключением. RAII убирает эту забывчивость из уравнения: если ресурс завёрнут в объект, то освобождение происходит автоматически и его физически невозможно забыть, потому что оно не завязано на то, что программист не забудет дописать строчку — оно завязано на языковую гарантию вызова деструктора.

Именно на этой идее построены все умные указатели, RAII-обёртки для мьютексов (std::lock_guard), файловые потоки (std::ifstream сам закрывает файл в деструкторе) и вообще большая часть стандартной библиотеки C++. Когда на собеседовании спрашивают «почему в C++ не нужен try/finally, как в Java», правильный ответ — RAII: конструктор/деструктор объекта на стеке уже даёт эту гарантию без явного finally.

Важный нюанс, который любят проверять: RAII работает только для объектов, у которых есть время жизни, управляемое языком — то есть объектов на стеке (автоматическое хранение) или полей внутри других объектов. Если ты создал объект через new и никуда его не завернул, RAII тебе не поможет — сырой указатель сам по себе не имеет деструктора, который бы что-то освобождал. Именно поэтому следующий шаг — обернуть такой указатель в умный указатель, который сам является RAII-объектом на стеке.

Проверьте себя
1. Что произойдёт с локальным объектом на стеке, если функция прервётся исключением до своего конца?
AЕго деструктор не вызовется — исключение «перепрыгивает» через объект
BЕго деструктор вызовется во время раскрутки стека (stack unwinding)
CЕго деструктор вызовется, но только если объект был создан через new
DПрограмма аварийно завершится без вызова деструкторов
2. На чём основана идиома RAII?
AНа сборщике мусора, который следит за неиспользуемыми объектами
BНа привязке времени жизни ресурса к времени жизни объекта через конструктор и деструктор
CНа ручном вызове delete в конце каждой функции
DНа блоках try/finally, как в Java