RAII: управление ресурсами через объекты

RAII — самая важная идиома C++. Её суть в одной фразе: «ресурс получается в конструкторе, освобождается в деструкторе». Из этого простого правила вырастает надёжное управление памятью, файлами, мьютексами и сетевыми соединениями.

RAII Утечки ресурсов Безопасность исключений lock_guard, ifstream, unique_ptr

Проблема: ресурсы нужно освобождать вручную

Программа работает с ресурсами — памятью, файлами, сетевыми соединениями, мьютексами. Каждый ресурс нужно явно освободить, когда он больше не нужен. Забыть — легко.

ручное управление — источник ошибок
void processFile(const string& path) {
    FILE* f = fopen(path.c_str(), "r");
    if (!f) return;

    char* buffer = new char[1024];

    // ... читаем из файла, парсим ...

    if (some_error_condition) {
        return;   // ← забыли освободить f и buffer!
    }

    delete[] buffer;
    fclose(f);
}
Три источника проблем:
  • Забыли освободить. Утечка ресурса: файл остаётся открытым, память занята.
  • Исключение. Если между получением и освобождением летит исключение — управление не дойдёт до fclose.
  • Двойное освобождение. Если освободили в двух ветках кода — программа может упасть.

Идея RAII в одной фразе

Resource Acquisition Is Initialization. Ресурс получается в конструкторе объекта и освобождается в его деструкторе. Пока объект жив — ресурс жив. Когда объект умирает (в том числе при исключении) — деструктор автоматически освобождает ресурс.
тот же код, но по-RAII
void processFile(const string& path) {
    ifstream f(path);
    if (!f) return;

    vector<char> buffer(1024);

    // ... читаем из файла, парсим ...

    if (some_error_condition) {
        return;   // ← f и buffer освободятся автоматически
    }

    // Никаких fclose, delete[] — всё сделает деструктор
}

Ключевые моменты:

Демонстрация: две стратегии и одно исключение

Один и тот же сценарий: получаем два ресурса, потом что-то идёт не так и летит исключение. Смотрите, что происходит с ресурсами.

main.cpp
1. Получаем файл f = open("data.txt")
2. Выделяем буфер buffer = new char[1024]
3. Что-то идёт не так → throw Exception("ошибка!")
4. Стек разворачивается, ищем catch
5. catch: обрабатываем ошибку
Готово к запуску
$ // нажмите «Шаг», чтобы запустить сценарий

Примеры RAII из стандартной библиотеки

Вы уже используете RAII, возможно, сами того не замечая.

Файлы: ifstream / ofstream

файл закроется сам
{
    ifstream f("data.txt");
    string line;
    while (getline(f, line)) {
        cout << line << endl;
    }
}   // ← f.close() вызывается автоматически

Память: unique_ptr / shared_ptr

память освободится сама
{
    auto p = make_unique<int[]>(100);
    p[0] = 42;
    // ... работаем ...
}   // ← delete[] вызывается автоматически

Мьютексы: lock_guard

мьютекс освободится сам
mutex mtx;

void safeFunction() {
    lock_guard<mutex> lock(mtx);
    // ... критическая секция ...
}   // ← mtx.unlock() вызывается автоматически,
    //    даже если внутри было исключение

Динамические массивы: vector / string

память освободится сама
{
    vector<int> v = {1, 2, 3, 4, 5};
    string s = "hello";
    // ... работаем ...
}   // ← вся память освободится автоматически

Пишем свой RAII-класс

Обёртка вокруг C-функции fopen / fclose. При создании объекта файл открывается, при разрушении — закрывается. Никаких утечек.

FileHandle.h
class FileHandle {
    FILE* fp;                     // владеем этим ресурсом
public:
    // Конструктор ЗАХВАТЫВАЕТ ресурс
    FileHandle(const char* path, const char* mode)
        : fp(fopen(path, mode)) {}

    // Деструктор ОСВОБОЖДАЕТ ресурс
    ~FileHandle() {
        if (fp) fclose(fp);
    }

    // Копирование ЗАПРЕЩЕНО: иначе два объекта будут закрывать один файл
    FileHandle(const FileHandle&) = delete;
    FileHandle& operator=(const FileHandle&) = delete;

    // Перемещение РАЗРЕШЕНО: владение переходит
    FileHandle(FileHandle&& other) noexcept
        : fp(other.fp) {
        other.fp = nullptr;
    }

    FILE* get() const { return fp; }
    operator bool() const { return fp != nullptr; }
};
Три обязательных элемента:
  1. Конструктор захватывает ресурс (или явно ничего не делает, если ресурса нет).
  2. Деструктор гарантированно освобождает ресурс.
  3. Копирование запрещено (или реализовано через подсчёт ссылок). Иначе два объекта станут «владельцами» одного ресурса — двойное освобождение.
использование — как обычный объект
void readConfig() {
    FileHandle f("config.txt", "r");
    if (!f) {
        cerr << "Не удалось открыть файл" << endl;
        return;
    }

    char line[256];
    while (fgets(line, sizeof(line), f.get())) {
        // обрабатываем строку
    }
}   // ← f.~FileHandle() вызывается автоматически, fclose тоже

Правила использования RAII

1. Каждому ресурсу — свой RAII-класс

Память — unique_ptr. Файлы — fstream. Мьютексы — lock_guard. Соединения с БД — своя обёртка. Не смешивайте ресурсы в одном классе.

2. Правило пяти / правило нуля

Если ваш класс владеет ресурсом, продумайте все пять операций: деструктор, конструктор копирования, оператор копирования, конструктор перемещения, оператор перемещения. Лучший вариант — правило нуля: не владеть ресурсом напрямую, а использовать уже готовые RAII-обёртки (unique_ptr, vector), и тогда ни один из пяти специальных методов писать не нужно.

3. Исключения в деструкторах

Деструктор не должен выбрасывать исключений. Если во время разворачивания стека (в ответ на другое исключение) деструктор тоже бросит исключение — программа завершится аварийно. Все деструкторы RAII-классов делайте noexcept (явно или неявно).

4. Время жизни объекта = время жизни ресурса

Создавайте RAII-объект как можно позже — прямо перед использованием ресурса. Разрушайте как можно раньше — оборачивайте в блоки { }, если ресурс нужен только в этом участке.

Антипаттерн: владеть ресурсом через сырой указатель

плохо
class Widget {
    int* data;   // сырой указатель
public:
    Widget(int n) : data(new int[n]) {}
    ~Widget() { delete[] data; }   // придётся не забыть
};

Хуже того: этот класс упадёт при копировании — два объекта будут удалять один и тот же массив. Правильно — использовать vector<int> или unique_ptr<int[]>.

Антипаттерн: ручное освобождение в нескольких точках выхода

плохо
int process() {
    FILE* f = fopen("data.txt", "r");
    if (!f) return -1;

    if (checkSomething() == false) {
        fclose(f);    // ← не забудь!
        return -2;
    }

    if (parse() == false) {
        fclose(f);    // ← не забудь!
        return -3;
    }

    fclose(f);        // ← и здесь не забудь!
    return 0;
}

Каждая новая ветка — новый шанс забыть fclose. С RAII достаточно объявить ifstream f("data.txt"); — и всё закроется само при любом выходе из функции.

Шпаргалка

Ресурс RAII-обёртка в стандартной библиотеке
Динамическая память (один объект)unique_ptr<T>, shared_ptr<T>
Динамический массивvector<T>, unique_ptr<T[]>
Файл для чтенияifstream
Файл для записиofstream
Файл для чтения и записиfstream
Мьютексlock_guard<mutex>, unique_lock<mutex>
Строкаstring
Сокет / соединение с БДсвоя RAII-обёртка (в стандартной библиотеке нет)
Главное. Правило RAII придумано не для красоты. Оно делает код безопасным по исключениям и устраняет целый класс ошибок — утечки ресурсов. Если у вас есть выбор между ручным управлением и RAII — почти всегда RAII.

Задание

  1. Возьмите функцию с ручным fopen/fclose и перепишите её с использованием ifstream.
  2. Напишите RAII-класс Timer, который в конструкторе запоминает время, а в деструкторе выводит, сколько прошло.
  3. Оберните mutex в lock_guard и объясните, что произойдёт, если внутри критической секции вылетит исключение.
  4. Что такое «правило нуля»? Приведите пример класса, для которого не нужно писать ни деструктор, ни конструктор копирования, ни оператор присваивания.
  5. Почему деструктор не должен выбрасывать исключений? Что произойдёт, если во время разворачивания стека деструктор бросит std::exception?
  6. Напишите RAII-класс DbConnection, который в конструкторе открывает соединение, а в деструкторе закрывает его. Запретите копирование, разрешите перемещение.