Добавить в избранное

RAII в C++: главная идиома управления ресурсами

Чтение ≈ 4 мин

RAII — фундамент современного C++: ресурс захватывается в конструкторе и освобождается в деструкторе. Разбираем применение к памяти, файлам, мьютексам, дескрипторам и пишем свои RAII-обёртки.

Что такое RAII простыми словами

RAII — Resource Acquisition Is Initialization, «захват ресурса есть инициализация». Идея: любой ресурс (память, файл, сокет, мьютекс, соединение с БД) оборачивается в объект. Конструктор объекта ресурс захватывает, деструктор — гарантированно освобождает, когда объект выходит из области видимости. Неважно, как происходит выход: нормальный возврат, break, return посреди функции или раскрутка стека при исключении — деструктор будет вызван всегда.

void process() {
    std::lock_guard lock(mutex);   // мьютекс захвачен
    // ... работаем ...
    // при выходе из функции — любому — мьютекс отпустится сам
}

До RAII тот же код выглядел бы так: захватили мьютекс, начали работать, получили исключение — мьютекс так и остался захваченным, дедлок навсегда. C++ превращает корректность в «бесплатную» — она следует из правил языка, а не из внимательности программиста.

Куда RAII уже встроен в стандартную библиотеку

Вы пользуетесь RAII постоянно, может, не подозревая об этом:

  • Память: std::string, std::vector, std::unique_ptr, std::shared_ptr — освобождают кучу сами.
  • Файлы: std::ifstream/std::ofstream закрывают дескриптор в деструкторе.
  • Блокировки: std::lock_guard, std::unique_lock, std::scoped_lock (C++17, для нескольких мьютексов сразу).
  • Потоки: std::jthread (C++20) делает join в деструкторе — поток не останется бесхозным.
  • Счётчики времени, скоуп-гварды: свои классы или gsl::final_action.

Пишем свою RAII-обёртку: файл в C-стиле

Старые C-API работают через сырые указатели и функции fclose, close, free. Оборачиваем их, чтобы нельзя было забыть освобождение:

class File {
    FILE* f_;
public:
    explicit File(const char* path, const char* mode) : f_(fopen(path, mode)) {
        if (!f_) throw std::runtime_error("не открыть файл");
    }
    ~File() { if (f_) fclose(f_); }

    // запрещаем копирование — владелец один
    File(const File&) = delete;
    File& operator=(const File&) = delete;

    // перемещение разрешаем
    File(File&& o) noexcept : f_(o.f_) { o.f_ = nullptr; }

    FILE* get() const { return f_; }
};

Три обязательных правила RAII-класса:

  • Конструктор захватывает ресурс или кидает исключение.
  • Деструктор освобождает, никогда не кидает исключений (noexcept по умолчанию).
  • Копирование запрещено или осмыслено: для единоличного владения — delete, для разделяемого — счётчик ссылок.

Пример: измерение времени через RAII

Класс, который в конструкторе запоминает время, а в деструкторе печатает длительность — scope-таймер:

class Timer {
    std::string name_;
    std::chrono::steady_clock::time_point t0_ = std::chrono::steady_clock::now();
public:
    explicit Timer(std::string name) : name_(std::move(name)) {}
    ~Timer() {
        auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
            std::chrono::steady_clock::now() - t0_).count();
        std::cout << name_ << ": " << ms << " ms\n";
    }
};

void heavy() {
    Timer t{"heavy"};
    // ... работа ...
}   // "heavy: 153 ms"

RAII и исключения: почему это работает

Магия — в гарантии языка: если объект полностью сконструирован на стеке, его деструктор вызовется и при раскрутке стека. Поэтому стандартный совет «ловите по const&, кидайте по значению, а ресурсы держите в RAII» делает исключения безопасными:

void risky() {
    auto data = std::make_unique<Buffer>(1024);
    mayThrow();          // исключение? не страшно — data удалится при раскрутке
    alsoMayThrow();
}

Деструктор, который сам бросает исключение, — антипаттерн: если он сработает во время раскрутки другого исключения, получите std::terminate. Деструкторы должны молча деградировать (поглощать ошибки или логировать).

Scoped guard для произвольных действий

Когда нет готовой обёртки, пишут универсальный scope-guard — объект с лямбдой, которая исполняется в деструкторе:

template<class F>
struct ScopeExit {
    F f;
    ~ScopeExit() { f(); }
};

void setup() {
    turnOnPower();
    ScopeExit off{[]{ turnOffPower(); }};  // выключится при любом выходе
    // ...
}

В С++26 ожидается std::scope_exit стандартно; до того — такие классы или библиотеки (GSL final_action).

RAII во встраиваемых системах

Для Arduino/микроконтроллеров RAII особенно ценен: там нет ОС, которая «приберётся» за утечкой. Обёртка над CS-пином SPI, над Wire.begin/end, над захватом прерываний — всё это RAII. Исключений на AVR обычно нет, но RAII работает и без них — деструктор вызывается при выходе из scope всё равно.

class SpiSelect {
    uint8_t pin_;
public:
    explicit SpiSelect(uint8_t pin) : pin_(pin) { digitalWrite(pin_, LOW); }
    ~SpiSelect() { digitalWrite(pin_, HIGH); }
};

void readSensor() {
    SpiSelect cs(SS_PIN);
    // SPI.transfer(...) — пин отпустится сам
}

Итог

RAII — это не фича, а способ мысли: каждый ресурс имеет владельца-объект, время жизни ресурса равно времени жизни объекта, деструкторы не бросают. Усвоив это, вы пишете код, который корректен при любых путях выхода — и перестаёте бояться исключений и ранних return.

RAII в стандартной библиотеке: полный список

Зная RAII, вы узнаёте его везде:

  • std::unique_ptr, shared_ptr, weak_ptr — память;
  • std::lock_guard, unique_lock, scoped_lock, shared_lock — блокировки;
  • std::fstream, ifstream, ofstream — файлы;
  • std::string, vector, map — динамические буферы;
  • std::jthread — поток с автоджойном;
  • std::experimental::scope_exit / сторонние gsl::final_action — произвольный cleanup.

Правило нуля: лучший класс — без деструктора

Если все ресурсы уже в RAII-обёртках, ваш класс не пишет деструктор вовсе:

class App {
    std::vector<Job> jobs_;
    std::unique_ptr<Connection> conn_;
    std::ofstream log_;
    // деструктор не нужен — всё умирает само
};

«Правило нуля»: класс, чьи члены сами управляют ресурсами, не требует ни деструктора, ни конструкторов копирования — компилятор сгенерирует корректные.

RAII и возврат ошибок

Иногда исключение недопустимо (real-time, embedded без исключений). RAII всё равно работает: деструктор вызывается при выходе из scope независимо от пути. Ресурс закрывается и при return раньше времени, и при break из цикла — код безопасен даже без механизма исключений.

Типичная реализация scope-guard из boost/GSL

#include <gsl/util>

void transaction() {
    beginWork();
    auto commit = gsl::finally([] { if (!committed) rollback(); });
    // работаем...
    committed = true;   // в конце — флаг отменяет rollback
}

Деструктор finally вызовется всегда — и откатит только если мы не успели закоммитить.

Итоговое правило

Перестаньте думать «где тут delete». Думайте «у какого объекта это время жизни». Ответ почти всегда — RAII-обёртка или контейнер. Если вы видите в коде delete, fclose, unlock, free — это место, где должен быть RAII-класс.

Поделиться
Понравился материал?

Комментарии

Добавить комментарий