При разработке на C++ время от времени приходится писать код, в котором исключения не должны возникать. Например, когда нам нужно написать не бросающий исключений swap для собственных типов или определить noexcept move-оператор для своего класса, или вручную реализовать нетривиальный деструктор.


В С++11 в язык был добавлен модификатор noexcept, который позволяет разработчику понять, что из помеченной noexcept-ом функции (или метода) исключения вылететь не могут. Поэтому функции с такой пометкой могут смело использоваться в контекстах, где исключения не должны возникать.


Например, если у меня есть вот такие типы и функции:


class first_resource {...};
class second_resource {...};

void release(first_resource & r) noexcept;
void close(second_resource & r);

и есть некий класс resources_owner, который владеет объектами типа first_resource и second_resource:


class resources_owner {
   first_resource first_resource_;
   second_resource second_resource_;
   ...
};

то я могу написать деструктор resources_owner следующим образом:


resources_owner::~resources_owner() noexcept {
   // Функция release() не бросает исключений, поэтому просто вызываем ее.
   release(first_resource_);

   // А вот функция close() может бросать исключения, поэтому
   // обрамляем ее try-catch.
   try{ close(second_resource_); } catch(...) {}
}

В каком-то смысле noexcept в C++11 сделал жизнь C++ разработчика легче. Но у текущей реализации noexcept в современном C++ есть одна неприятная сторона...


Компилятор не помогает контролировать содержимое noexcept функций и методов


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


resources_owner::~resources_owner() noexcept {
   release(first_resource_); // Нет try-catch вокруг вызова
   ...
}

то я напрашиваюсь на неприятности. Рано или поздно эта release() бросит исключение и все мое приложение упадет из-за автоматически вызванного std::terminate(). Еще хуже будет, если упадет не мое приложение, а чужое, в котором использовали мою библиотеку вот с таким вот проблемным деструктором для resources_owner.


Или другая вариация этой же проблемы. Допустим, что я не ошибся, что release() действительно помечена как noexcept. Была.


Была помечена в версии 1.0 сторонней библиотеки из которой я взял first_resource и release(). А потом, спустя несколько лет, я обновился до версии 3.0 этой библиотеки, но в версии 3.0 у release() уже нет модификатора noexcept.


Ну а что? Новая мажорная версия, запросто могли API поломать.


Только вот я, скорее всего, забуду поправить реализацию деструктора resources_owner-а. А уж если вместо меня поддержкой resource_owner-а занимается кто-то другой, кто никогда в этот деструктор и не заглядывал, то изменения в сигнатуре release() наверняка останутся незамеченными.


Поэтому лично мне в текущей реализации noexcept в C++ не нравится то, что компилятор никак не предупреждает программиста о том, что программист внутри noexcept метода/функции делает вызов бросающих исключения методов/функций.


Было бы лучше, если бы компилятор выдавал бы такие предупреждения.


Спасение утопающих дело рук самих утопающих


OK, компилятор не выдает никаких предупреждений. И с этим простому разработчику ничего не поделать. Не заниматься же модификаций C++ компилятора под собственные нужды. Особенно если приходится пользоваться не одним компилятором, а разными версиями разных C++ компиляторов.


А можно ли получить помощь от компилятора не залезая в его потроха? Т.е. можно ли самостоятельно сделать какие-то инструменты для контроля содержимого noexcept методов/функций, пусть даже дендро-фекальным способом?


Можно. Коряво, но можно.


Откуда ноги растут?


Описанный в данной статье подход был опробован на практике при подготовке очередной версии нашего небольшого встраиваемого HTTP-сервера RESTinio.


Дело в том, что по мере наполнения RESTinio функциональностью мы упустили из виду вопросы exception safety в нескольких местах. В частности, со временем выяснилось, что исключения иногда могут вылетать из переданных в Asio коллбэков (чего быть не должно), а также исключения, в принципе, могут вылетать и при чистке ресурсов.


К счастью, на практике эти проблемы ни разу не проявились, но технический долг копился и с этим нужно было что-то делать. Причем делать нужно было что-то с кодом, который уже был написан. Т.е. работающий не-noexcept код следовало преобразовать в работающий noexcept код.


Сделано это было при помощи нескольких макросов, которые были расставлены по коду в нужных местах. Например, тривиальный случай:


template< typename Message_Builder >
void
trigger_error_and_close( Message_Builder msg_builder ) noexcept
{
   // An exception from logger/msg_builder shouldn't prevent
   // a call to close().
   restinio::utils::log_error_noexcept( m_logger, std::move(msg_builder) );

   RESTINIO_ENSURE_NOEXCEPT_CALL( close() );
}

А вот менее тривиальный фрагмент:


void
reset() noexcept
{
   RESTINIO_STATIC_ASSERT_NOEXCEPT(m_context_table.empty());
   RESTINIO_STATIC_ASSERT_NOEXCEPT(
         m_context_table.pop_response_context_nonchecked());
   RESTINIO_STATIC_ASSERT_NOEXCEPT(m_context_table.front());
   RESTINIO_STATIC_ASSERT_NOEXCEPT(m_context_table.front().dequeue_group());

   RESTINIO_STATIC_ASSERT_NOEXCEPT(make_asio_compaible_error(
         asio_convertible_error_t::write_was_not_executed));

   for(; !m_context_table.empty();
      m_context_table.pop_response_context_nonchecked() )
   {
      const auto ec =
         make_asio_compaible_error(
            asio_convertible_error_t::write_was_not_executed );

      auto & current_ctx = m_context_table.front();
      while( !current_ctx.empty() )
      {
         auto wg = current_ctx.dequeue_group();

         restinio::utils::suppress_exceptions_quietly( [&] {
               wg.invoke_after_write_notificator_if_exists( ec );
            } );
      }
   }
}

Использование этих макросов несколько раз ударило по рукам, указав на места, которые я по недосмотру воспринимал как noexcept, но которые таковыми не были.


Так что описываемый далее подход, конечно же, является самодельным лисапедом с квадратными колесами, но он ездиит… В смысле работает.


Далее в статье речь пойдет уже о реализации, которая была выделена из кода RESTinio в отдельный набор макросов.


Суть подхода


Суть подхода состоит в том, чтобы передать утверждение/оператор (stmt), которое нужно проверить на noexcept, в некий макрос. Этот макрос задействует static_assert(noexcept(stmt), msg) для проверки того, что stmt действительно noexcept, после чего подставляет stmt в код.


По сути, вот такое:


ENSURE_NOEXCEPT_STATEMENT(release(some_resource));

будет заменено на что-то вроде:


static_assert(noexcept(release(some_resource)),
   "release(some_resource) is expected to be noexcept");
release(some_resource);

Почему был сделан выбор в пользу макросов?


В принципе, можно было бы обойтись без макросов и можно было писать static_assert(noexcept(...)) прямо в коде непосредственно перед проверяемыми действиями. Но у макросов есть, по меньшей мере, пара достоинства, которые склонили чашу весов в пользу использования именно макросов.


Во-первых, макросы уменьшают дублирование кода. Есть сравнить:


static_assert(noexcept(release(some_resource)),
   "release(some_resource) is expected to be noexcept");
release(some_resource);

и


ENSURE_NOEXCEPT_STATEMENT(release(some_resource));

то видно, что с макросами основное выражение, т.е. release(some_resource) можно записать только однажды. Что уменьшает вероятность "расползания" кода со временем, при его сопровождении, когда в одном месте исправление внесли, а во втором — забыли.


Во-вторых, макросы и, соответственно, спрятанные за ними проверки, можно очень легко отключить. Скажем, если обилие static_assert-ов стало негативно сказываться на скорости компиляции (хотя я такого эффекта не заметил). Или, что более существенно, при обновлении какой-нибудь сторонней библиотеки ошибки компиляции от спрятанных за макросами static_assert-ов, могут посыпаться прямо рекой. Временное отключение макросов может позволить провести плавное обновление кода, включая проверочные макросы последовательно сперва в одном файле, потом во втором, потом в третьем и т.д.


Так что макросы, хоть и являются устаревшей и крайне спорной фичей в C++, в данном конкретном случае жизнь разработчику упрощают.


Основной макрос ENSURE_NOEXCEPT_STATEMENT


Основной макрос ENSURE_NOEXCEPT_STATEMENT реализуется тривиально:


#define ENSURE_NOEXCEPT_STATEMENT(stmt)    do {       static_assert(noexcept(stmt), "this statement is expected to be noexcept: " #stmt);       stmt;    } while(false)

Он применяется для проверки того, что вызываемые методы/функции действительно являются noexcept и их вызовы не нужно обрамлять блоками try-catch. Например:


class some_complex_container {
   one_container first_data_part_;
   another_container second_data_part_;
   ...
public:
   friend void swap(some_complex_container & a, some_complex_container & b) noexcept {
      using std::swap;
      // Если swap не noexcept, то будет ошибка компиляции.
      ENSURE_NOEXCEPT_STATEMENT(swap(a.first_data_part_, b.first_data_part_));
      ENSURE_NOEXCEPT_STATEMENT(swap(a.second_data_part_, b.second_data_part_));
      ...
   }
   ...
   void clean() noexcept {
      // Если clean() не noexcept, то будет ошибка компиляции.
      ENSURE_NOEXCEPT_STATEMENT(first_data_part_.clean());
      ENSURE_NOEXCEPT_STATEMENT(second_data_part_.clean());
      ...
   }
   ...
};

В дополнение есть еще и макрос ENSURE_NOT_NOEXCEPT_STATEMENT. Он применяется для того, чтобы убедиться, что требуется дополнительный блок try-catch вокруг вызова, чтобы возможные исключения не улетели наружу:


class some_resource_owner {
   some_resource resource_;
   ...
public:
   ~some_resource_owner() noexcept {
      try {
         // Если release вдруг окажется noexcept, то try-catch не нужны и мы
         // узнаем об этом во время компиляции.
         ENSURE_NOT_NOEXCEPT_STATEMENT(release(resource_));
      } catch(...) {}
      ...
   }
   ...
};

Вспомогательные макросы STATIC_ASSERT_NOEXCEPT и STATIC_ASSERT_NOT_NOEXCEPT


К сожалению, макросы ENSURE_NOEXCEPT_STATEMENT и ENSURE_NOT_NOEXCEPT_STATEMENT могут применяться только для утверждений/операторов (statements), но не для возвращающих значение выражений (expressions). Т.е. нельзя посредством ENSURE_NOEXCEPT_STATEMENT написать так:


auto resource = ENSURE_NOEXCEPT_STATEMENT(acquire_resource(params));

Поэтому ENSURE_NOEXCEPT_STATEMENT не получается использовать, например, в циклах, где часто приходится писать что-то вроде:


for(auto i = something.get_first(); i != some_other_object; i = i.get_next()) {...}

и требуется убедиться, что вызовы get_first(), get_next(), а также операции присваивания новых значений для i не бросают исключений.


Для борьбы с такими ситуациями были написаны макросы STATIC_ASSERT_NOEXCEPT и STATIC_ASSERT_NOT_NOEXCEPT, за которыми спрятаны только static_assert-ы и ничего больше. С помощью этих макросов можно достичь нужного мне результата каким-то таким образом (компилябильность именно этого фрагмента не проверялась):


STATIC_ASSERT_NOEXCEPT(something.get_first());
STATIC_ASSERT_NOEXCEPT(something.get_first().get_next());
STATIC_ASSERT_NOEXCEPT(std::declval<decltype(something.get_first())>() =
   something.get_first().get_next());
for(auto i = something.get_first(); i != some_other_object; i = i.get_next()) {...}

Очевидно, что это не есть самое лучшее решение, т.к. оно приводит к дублированию кода и повышает опасность его "расползания" при дальнейшем сопровождении. Но в качестве первого шага эти простейшие макросы оказались полезны.


Библиотека noexcept-ctcheck


Когда я поделился этим опытом у себя в блоге и в Facebook-е, то поступило предложение оформить описанные выше наработки в виде отдельной библиотеки. Что и было сделано: на github-е теперь лежит малюсенькая header-only библиотека noexcept-compile-time-check (или noexcept-ctcheck, если экономить на буквах). Так что все вышеописанное можно взять и попробовать. Правда там названия макросов чуть подлинее, чем использовано в статье. Т.е. NOEXCEPT_CTCHECK_ENSURE_NOEXCEPT_STATEMENT вместо ENSURE_NOEXCEPT_STATEMENT.


Что в noexcept-ctcheck не попало (пока?)


Есть желание сделать макрос ENSURE_NOEXCEPT_EXPRESSION, который можно было бы использовать вот так:


auto resource = ENSURE_NOEXCEPT_EXPRESSION(acquire_resource(params));

В первом приближении он мог бы выглядеть как-то так:


#define ENSURE_NOEXCEPT_EXPRESSION(expr)   ([&]() noexcept -> decltype(auto) {      static_assert(noexcept(expr), #expr " is expected to be noexcept");      return expr;   }())

Но есть смутные подозрения, что здесь есть некие подводные камни, о которых я еще не задумывался. В общем, до ENSURE_NOEXCEPT_EXPRESSION руки пока не дошли :(


А если помечтать?


Моя давняя мечта — это заиметь в C++ noexcept-блок, в котором бы компилятор сам проверял на бросание исключений и выдавал бы предупреждения, если исключения могут быть брошены. Мне кажется, что это упростило бы написание exception-safe кода. Причем не только в озвученных выше очевидных случаях (swap, move-операторы, деструкторы). Например, noexcept-блок мог бы помочь вот в такой ситуации:


void modify_some_complex_data() {
   // Выполняем предварительные действия.
   one_container_.modify();
   // Отмечаем, что состояние изменилось. Важно, что здесь мы не ждем исключений.
   // В противном случае следовало делать это внутри блока try.
   noexcept { current_age_.increment(); }
   // Если далее возникают исключения, то нужно отменить уже сделанные изменения.
   try {
      another_container_.modify();
      ...
   }
   catch(...) {
      noexcept { // Делаем действия, которые не должны бросать исключений.
         current_age_.decrement();
         one_container_.rollback_modifications();
      }
      throw;
   }
}

Здесь для корректности кода очень важно, чтобы действия, выполняемые внутри noexcept-блоков не бросали исключений. И если компилятор сможет за этим проследить, то это будет серьезное подспорье для разработчика.


Но, возможно, noexcept-блок — это лишь частный случай более общей проблемы. А именно: проверки ожиданий программиста о том, что какой-то блок кода обладает определенными свойствами. Будь то отсутствие исключений, отсутствие побочных эффектов, отсутствие рекурсии, гонок данных и пр.


Размышления на эту тему пару лет назад привели к идее атрибутов implies и expects. Дальше заметки в блоге эта идея не пошла, т.к. пока она лежит в стороне от моих текущих интересов и возможностей. Но вдруг она кому-то будет интересна и кого-то подтолкнет к созданию чего-то более жизнеспособного.


Заключение


В данной статье я попробовал рассказать о своем опыте упрощения написания exception-safe кода. Использование макросов, конечно же, не делает код красивее и компактнее. Но это работает. И коэффициент моего спокойного сна даже такие примитивные макросы повышают весьма существенно. Так что, если кто-то еще не задумывался о том, как контролировать содержимое собственных noexcept методов/функций, то может быть данная статья подтолкнет вас к размышлениям на эту тему.


А если кто-то нашел способ упростить себе жизнь при написании noexcept-кода, то было бы интересно узнать, что это за способ, в чем он помогает, а в чем нет. И насколько вы сами довольны тем, что используете.

Комментарии (7)


  1. Goron_Dekar
    10.09.2019 13:39
    +1

    О! Не мне одному нужно больше контрактного программирования в плюсах.


    1. Goron_Dekar
      10.09.2019 13:50

      Мне вот очень не хватает контракта на constexpr. Сейчас заворачиваю такие блоки в шаблон, но это костыль.


      1. eao197 Автор
        10.09.2019 13:51

        Мне вот очень не хватает контракта на constexpr.

        А что это такое?


        1. Goron_Dekar
          11.09.2019 13:08

          Если грубо, механизма, позволяющего пометить участок кода как участок, гарантированно вычисляемый на этапе компиляции. С остановкой компиляции при возникновении в этом участке исполняемого блока, попадающего в исполняемый код.


          1. eao197 Автор
            11.09.2019 13:18

            Так вроде consteval из C++20 это решает или нет?


  1. 0xd34df00d
    11.09.2019 21:02

    Я бы скорее подёргал libclang и написал простенький линтер, который проверяет, являются ли все прямые дети в AST рассматриваемой функции noexcept. Да, это отдельный инструмент и отдельный этап сборки, что лучше избегать, но макросами-то у вас то дублирование кода, то можно что-то пропустить.


    1. eao197 Автор
      11.09.2019 22:09

      Я бы скорее подёргал libclang и написал простенький линтер

      Для тех, кто никогда не дергал libclang, этот линтер простеньким точно не будет.