Перейти к содержимому

RAII: модель фундаментальных ресурсов

Изучите RAII (Resource Acquisition Is Initialization) — базовый шаблон C++ для управления ресурсами, безопасными для исключений. Внедрите обертки RAII, поймите lock guard и scoped lock и узнайте, почему типы RAII должны быть только для перемещения или пересчитаны.

RAII: Приобретение в Constructor, выпуск в Destructor

RAII (Resource Acquisition Is Initialization) является единственным наиболее важным шаблоном в C++. Идея проста:

1. Приобрести ресурс в конструкторе** (открыть файл, заблокировать mutex, выделить память, подключиться к базе данных).
2. Выпустить ресурс в деструкторе (закрыть файл, разблокировать mutex, свободную память, отключить).

Поскольку C++ гарантирует, что деструкторы вызываются, когда объект выходит из-под контроля — даже если исключение забрасывается — RAII делает управление ресурсами автоматическим и безопасным. Вы никогда не забываете выпустить, и вы никогда не выпускаете дважды.

Каждый хорошо разработанный тип C++, который управляет ресурсом, использует RAII: std::string, std::vector, std::unique_ptr, std::fstream, std::lock_guard, std::unique_lock.

Внедрение RAII Wrapper

Вот полная обертка RAII для дескриптора файлов (POSIX). Он приобретает в конструкторе, выпускает в деструкторе и поддерживает семантику перемещения для передачи права собственности.

raii_file_descriptor.cpp
#include <unistd.h>   // open, close, read, write
#include <fcntl.h>    // O_RDONLY, etc.
#include <stdexcept>
#include <utility>
#include <iostream>

class FileDescriptor {
    int fd_ = -1;  // -1 = no resource
public:
    // Acquire: open file in constructor
    explicit FileDescriptor(const char* path, int flags)
        : fd_(::open(path, flags)) {
        if (fd_ < 0) throw std::runtime_error("Cannot open file");
    }

    // Release: close file in destructor
    ~FileDescriptor() {
        if (fd_ >= 0) ::close(fd_);
    }

    // Non-copyable (copying a file descriptor is meaningless)
    FileDescriptor(const FileDescriptor&) = delete;
    FileDescriptor& operator=(const FileDescriptor&) = delete;

    // Moveable (transfer ownership)
    FileDescriptor(FileDescriptor&& other) noexcept
        : fd_(std::exchange(other.fd_, -1)) {}

    FileDescriptor& operator=(FileDescriptor&& other) noexcept {
        if (this != &other) {
            if (fd_ >= 0) ::close(fd_);
            fd_ = std::exchange(other.fd_, -1);
        }
        return *this;
    }

    int get() const { return fd_; }
};

int main() {
    try {
        FileDescriptor fd("/tmp/raii_test.txt", O_RDONLY);
        std::cout << "File opened: fd=" << fd.get() << '\n';
        // Use fd...
    } catch (const std::exception& e) {
        std::cerr << e.what() << '\n';
    }
    // fd.~FileDescriptor() called automatically — file closed
    // Even if an exception was thrown, the file is closed
}

Безопасность через RAII

Без RAII безопасный код требует утомительных блоков для каждого ресурса. С RAII он автоматический — каждый деструктор стреляет во время раскручивания стопки.

exception_safety.cpp
#include <memory>
#include <mutex>
#include <fstream>
#include <vector>
#include <stdexcept>

std::mutex g_mutex;

// WITHOUT RAII — manual cleanup is error-prone
void bad_example() {
    std::mutex* m = &g_mutex;
    m->lock();
    int* data = new int[100];
    // If this throws, mutex stays locked AND data leaks!
    // process(data);
    delete[] data;
    m->unlock();
}

// WITH RAII — automatic, exception-safe cleanup
void good_example() {
    std::lock_guard<std::mutex> lock(g_mutex);  // RAII lock
    auto data = std::make_unique<int[]>(100);   // RAII allocation
    // If this throws:
    //   1. data's destructor frees the memory
    //   2. lock's destructor unlocks the mutex
    //   Stack unwinding handles everything
    // process(data.get());
}

// Multiple resources — RAII composes naturally
void multi_resource() {
    std::lock_guard<std::mutex> lock(g_mutex);
    auto buffer = std::make_unique<char[]>(4096);
    std::ofstream file("/tmp/output.txt");

    // If any operation throws, ALL resources are released
    // in reverse order of construction:
    //   1. file closed
    //   2. buffer freed
    //   3. mutex unlocked
}

lock guard, уникальный lock & scoped lock

Стандартная библиотека предоставляет обертки RAII для мутексов:

std::lock_guard — самый простой замок RAII. Замки в конструкторе, разблокировки в деструкторе. Неподвижный, некопируемый. Используйте, когда вам нужен простой замок.

std::unique_lock — гибкий замок RAII. Поддерживает отложенную блокировку, временную блокировку, ручную разблокировку / блокировку и может перемещаться. Требуется для использования с std::condition_variable.

std::scoped_lock (C++17) — блокирует несколько мутексов одновременно с использованием алгоритма избежания тупика. Это предпочтительный тип замка при блокировке одной или нескольких мутексов.

std::mutex m1, m2;
// C++17: lock both without deadlock risk
std::scoped_lock lock(m1, m2);
Подводный камень

1 Неназванные объекты RAII немедленно уничтожаются:

std::lock_guard<std::mutex>(mtx);  // BUG! Temporary — locks and immediately unlocks
std::lock_guard<std::mutex> lock(mtx);  // Correct — named, lives until scope end

2. Никогда наивно не копируйте типы RAII: Копирование ручки файла или блокировки mutex не имеет смысла. Типы RAII должны быть:
Только для движения (например, unique_ptr, unique_lock) для исключительного владения, или
Справочная информация (например, shared_ptr) для долевого владения.

3. Деструктор не должен бросать: Если деструктор бросает во время раскручивания стека (активно другое исключение), называется std::terminate. Деструкторы noexcept по умолчанию в C++11+.

Лучшая практика

1. Каждый ресурс должен принадлежать объекту RAII — памяти, файлам, замкам, розеткам, соединениям с базами данных, GPU-ручкам.
2. Один ресурс на класс RAII — держите обертки сфокусированными.
3. Удалить операции по копированию, если только копирование ресурса не имеет смысла.
4. Внедрить операции по перемещению для обеспечения передачи прав собственности.
5. Использовать std::exchange в операциях перемещения — он атомарно считывает и сбрасывает источник: fd_ = std::exchange(other.fd_, -1);
6. Предпочтительно стандартные типы RAII (unique_ptr, lock_guard, fstream) — пишут только пользовательские обертки для ресурсов без стандартных оберток.

Типы RAII (правило нуля)

Окончательным выражением RAII является правило нуля: если все переменные членов являются типами RAII, ваш класс не нуждается в специальных членах, объявленных пользователем. Сгенерированный компилятором деструктор, копирование и перемещение операций делают правильные вещи автоматически.

class DatabaseSession {
    std::unique_ptr<Connection> conn_;    // RAII: auto-closed
    std::unique_lock<std::mutex> lock_;   // RAII: auto-unlocked
    std::vector<std::string> log_;        // RAII: auto-freed
    // No destructor, copy, or move needed — all member types handle it
};

Правило нуля – это цель. Правило пяти (явно определяет всех 5 специальных членов) относится к низкоуровневым типам обертки ресурсов. Нет середины — если вы объявите кого-то из пяти, объявите всех.

Ключевые выводы
  • RAII: приобретение в конструкторе, выпуск в деструкторе — автоматический и безопасный
  • Деструкторы называются во время раскручивания стека, поэтому RAII обрабатывает исключения естественным образом.
  • Типы RAII должны быть только для перемещения (эксклюзивное владение) или учитываться (совместное владение)
  • Используйте std::scoped_lock (C++17) для блокировки мультимутекса без тупика
  • Неназванные объекты RAII уничтожаются сразу — всегда дайте им имя
  • Следуйте правилу нуля: используйте RAII, чтобы компилятор генерировал правильные специальные члены.

Тест — проверьте свои знания

(15 XP)

1. Что не так с `std::lock_guard<std::mutex>(mtx);` (примечание: нет переменного имени)?

2. Почему RAII-типы обычно удаляют свои операции с копиями?

3. Что такое правило нуля?