Как стать автором
Обновить

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

Уровень сложностиСредний
Время на прочтение6 мин
Количество просмотров3.6K

Идиома 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:
   // Переключение состояния, через жизненный цикл класса 
   explicit 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 был прост, теперь он стал умным.

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

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

Теги:
Хабы:
+15
Комментарии3

Публикации

Работа

Программист C++
96 вакансий
QT разработчик
5 вакансий

Ближайшие события