RAII 2.0: RAII как архитектурный инструмент в C++

от автора

Идиома RAII — давно зарекомендовал себя как удобный способ автоматического управления ресурсами в C++. Обычно мы применяем его для управления памятью, файловыми дескрипторами или мьютексами. Однако что, если расширить понятие RAII до управления не только физическими ресурсами, но и логическими контрактами и состояниями системы?

В этой статье я хочу поговорить о том, как RAII можно использовать для контроля жизненного цикла асинхронных операций, транзакций или подписок, гарантируя их корректное завершение или откат до прежнего состояния.


Классическое определение RAII — это автоматическое управление ресурсом через объект, чья ответственность завершается в деструкторе. Это удобно и безопасно. Но если абстрагироваться от «ресурса» и подумать шире — мы получаем мощный механизм, который может управлять чем угодно, у кого есть чётко определённый жизненный цикл.

Примеры в статье сделаны намеренно упрощёнными и потенциально могут содержать уязвимые места, требующие доработки в реальном коде.

Пример 1: Управление подписками

Предположим, у нас есть система эвентов, где подписка оформляется через объект Subscription. В классическом варианте реализация выглядит следующим образом:

class Subscription { public:    using UnsubscribeFunc = std::function< void() >;    // Базовый конструктор, принимающий функцию отписки    Subscription( UnsubscribeFunc func )       : mUnsubscribeFunc( std::move( func ) )    {    }    // В деструкторе происходит выполнение логики удаления из очереди подписчиков    ~Subscription()    {       if( mUnsubscribeFunc )          mUnsubscribeFunc();    }    // Стандартные методы копирования лучше запретить    Subscription( const Subscription& ) = delete;    Subscription& operator=( const Subscription& ) = delete;     // Оставляем только перемещение, это безопасно    Subscription( Subscription&& other ) noexcept       : mUnsubscribeFunc( std::move( other.mUnsubscribeFunc ) )    {       // Если не затереть у перемещаемого объекта        // деструктор other выполнит отписку практически в тот же момент       other.mUnsubscribeFunc = nullptr;    }    Subscription& operator=( Subscription&& other ) noexcept    {       if( this != &other )       {          mUnsubscribeFunc = std::move( other.mUnsubscribeFunc );          other.mUnsubscribeFunc = nullptr;       }       return *this;    }     private:    UnsubscribeFunc mUnsubscribeFunc; };

Далее для наглядности, в рамках паттерна «Наблюдатель» реализуем Observable, в котором подписка оформлена через RAII:

class Observable { public:    using Callback = std::function< void( int ) >;     // Подписка: возвращает RAII-объект Subscription с уникальным идентификатором    Subscription Subscribe( Callback cb )    {       std::lock_guard< std::mutex > lock( mMutex );       // Исключительно для примера сделаем уникальность подписки через счетчик       uint64_t id = ++mIdCounter;       mSubscribers.emplace_back( SubscriptionEntry{ id, std::move( cb ) } );       return Subscription(           [ this, id ]()          {             // Здесь то мы и реализуем метод отписки для деструктора Subscription             std::lock_guard< std::mutex > lock( mMutex );             auto it = std::remove_if( mSubscribers.begin(), mSubscribers.end(),                                      [ id ]( const SubscriptionEntry& entry ) { return entry.mId == id; });             if( it != mSubscribers.end() )             {                mSubscribers.erase( it, mSubscribers.end() );                std::cout << "[Subscription] Отписка выполнена для id: " << id << "\n";             }          } );    }     // Эмитирование события    void Emit( int value )    {       std::lock_guard< std::mutex > lock( mMutex );       for( const auto& entry : mSubscribers )       {          entry.mCallback( value );       }    }  private:    struct SubscriptionEntry    {       uint64_t mId;       Callback mCallback;    };    std::vector< SubscriptionEntry > mSubscribers;    std::mutex mMutex;    std::atomic< uint64_t > mIdCounter{ 0 }; };

Пример использования:

{   // Выполнение лямбды плотно завязано на длительность жизни sub      auto sub = obs.subscribe([](int val) { std::cout << "value: " << val << "\n";});     obs.emit(10);     obs.emit(100); } // Здесь sub уничтожен — выполнена отписка  obs.emit(500); // Данный эмит не будет пойман в рамках подписки sub

Благодаря RAII, управление подпиской становится безопасным и предсказуемым. Мы больше не нуждаемся в ручных вызовах Unsubscribe(), поскольку область видимости того, кто владеет подпиской, определяет её жизненный цикл.


Пример 2: Логические контракты и транзакции

Рассмотрим, как RAII может быть приспособлен для логических контрактов: управление транзакцией и логика отката. Предположим, что у нас есть логическая операция — транзакция, которую необходимо либо зафиксировать, либо откатить в случае ошибки. Пример реализации транзакции с методами Commit и Rollback:

struct Transaction {    int mId;    void Commit()    {       std::cout << "Transaction " << mId << " committed.\n";    }    void Rollback()    {       std::cout << "Transaction " << mId << " rolled back.\n";    } };

А также guard, который будет косвенно управлять состоянием транзакции:

template< typename T > class OwnershipGuard { public:    OwnershipGuard( T* ptr, std::function< void( T* ) > rollbackAction )       : mPtr( ptr )       , mRollbackAction( std::move( rollbackAction ) )       , mRollbackEnabled( true )    {    }     ~OwnershipGuard()    {       if( mRollbackEnabled && mPtr )       {          // Если откат всё ещё включён, вызываем rollback          mRollbackAction( mPtr );       }    }    // Аналогично предыдущему примеру - запрещено для устранения дублей    OwnershipGuard( const OwnershipGuard& ) = delete;    OwnershipGuard& operator=( const OwnershipGuard& ) = delete;     OwnershipGuard( OwnershipGuard&& other ) noexcept       : mPtr( other.mPtr )       , mRollbackAction( std::move( other.mRollbackAction ) )       , mRollbackEnabled( other.mRollbackEnabled )    {       other.mRollbackEnabled = false;    }    OwnershipGuard& operator=( OwnershipGuard&& other ) noexcept    {       if( this != &other )       {          if( mRollbackEnabled && mPtr )          {             mRollbackAction( mPtr );          }          mPtr = other.mPtr;          mRollbackAction = std::move( other.mRollbackAction );          mRollbackEnabled = other.mRollbackEnabled;          other.mRollbackEnabled = false;       }       return *this;    }    // Будет вызвано лишь в случае успешного исполнения логики по транзакции    void Release()    {       mRollbackEnabled = false;    }     T* Get() const    {       return mPtr;    }  private:    T* mPtr;    std::function< void( T* ) > mRollbackAction;    bool mRollbackEnabled; };

Использование:

void ProcessTransaction() {    // Создаем абстрактную транзакцию со своей логикой    Transaction txn{ 42 };    // Устанавливаем лямбду роллбека на случай возможных неприятностей    OwnershipGuard< Transaction > guard( &txn, []( Transaction* t ) { t->Rollback(); } );     // Сделаем заведомо ложным (можно выбросить здесь исключение)    bool success = false;        if( success )    {       guard.Release();       txn.Commit();    }    // Иначе rollback при выходе из области видимости  }

Таким образом, этот guard служит контрактом: если транзакция не завершена явно, она автоматически откатывается. RAII берёт на себя ответственность за корректное логическое завершение транзакции, избавляя от необходимости повторных проверок.


Пример 3: Управление асинхронными операциями в многопоточном окружении

RAII особенно важен в многопоточной среде, поскольку гарантирует корректное освобождение ресурсов даже в случае возникновения исключений. Пример ниже показывает применение RAII для управления флагом отмены асинхронных задач:

class CancelGuard { public:    // Переключение состояния, через жизненный цикл класса     exp licit CancelGuard( std::atomic< bool >& cancelFlag )       : mCancelFlag( cancelFlag )    {       mCancelFlag.store( false, std::memory_order_release );    }     ~CancelGuard()    {       mCancelFlag.store( true, std::memory_order_release );    }     CancelGuard( const CancelGuard& ) = delete;    CancelGuard& operator=( const CancelGuard& ) = delete;  private:    std::atomic< bool >& mCancelFlag; };
// Пример плохой, но асинхронной задачи: void AsyncOperation( std::atomic< bool >& cancelFlag ) {    using namespace std::chrono_literals;    while( !cancelFlag.load( std::memory_order_acquire ) )    {       std::cout << "Working...\n";       std::this_thread::sleep_for( 100ms );    }    std::cout << "Operation cancelled.\n"; }   int main() {    std::atomic< bool > cancel_flag{ false };    std::future< void > future;    {       // RAII: при выходе из блока произойдёт отмена всех асинхронных задач       CancelGuard guard( cancel_flag );         // Асинхронный запуск       future = std::async( std::launch::async, AsyncOperation, std::ref( cancel_flag ));        // Имитируем работу       std::this_thread::sleep_for( std::chrono::seconds( 1 ) );        // За данным блоком future-задачи теряют актуальность - будут отменены     }    // Данный wait() завершится мгновенно    future.wait(); }

Как видно из примера, объект CancelGuard управляет жизненным циклом отмены: пока он существует, асинхронная операция считается актуальной. При уничтожении объекта происходит автоматическая отмена, что гарантирует согласованность работы системы.

⚠️ Важно: безопасность кода в деструкторах

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

По возможности:

  • Делегируйте опасные действия (сеть, ввод-вывод) из деструктора в асинхронный или отложенный механизм (например, std::jthread, std::async и т.д.).

  • Используйте деструктор только для гарантированного и безопасного изменения локального состояния.

  • При необходимости — подавляйте исключения внутри деструктора и логируйте их.

RAII не отменяет здравого смысла — он усиливает архитектуру, но требует ответственности в реализации.


Заключение

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

Если вы проектируете систему, в которой присутствуют:

  • подписки,

  • транзакции,

  • асинхронные ожидания,

  • жизненные циклы логических фаз и т.д. и т.д.

подумайте: можно ли управлять этим через RAII? Если да — вы получите не просто лаконичный код, а архитектуру, устойчивую к ошибкам, исключениям и забывчивости.

RAII был прост, теперь он стал умным.

Косинцев Артём

Инженер-программист


ссылка на оригинал статьи https://habr.com/ru/articles/901092/