пятница, 6 сентября 2019 г.

[prog.c++] Проверки на noexcept "для бедных"

Я принадлежу к числу тех, кто считает, что исключения -- это гораздо более предпочтительный способ информирования о проблемах, чем возвращаемые коды ошибок. Надеюсь на то, что при дальнейшей эволюции С++ найдут способ сделать исключения предсказуемее и еще дешевле. После чего поводов отказываться от исключений, даже при низкоуровневом программировании, не останется.

Многие жалуются, что в коде не видно, откуда исключения могут вылететь. ИМХО, это просто из-за недостатка опыта. Если заставить себя думать о том, что исключения могут вылететь откуда угодно, то со временем к этому привыкаешь. И начинаешь понимать, что на самом деле гораздо более важно иметь возможность увидеть, откуда исключение вылететь не может.

В C++11 в этом направлении сделали важный шаг: добавили в язык спецификатор noexcept. Так что теперь из прототипа функции можно узнать о том, что она исключений не бросает.

Но, к сожалению, лично для меня этого спецификатора недостаточно. Поскольку люди совершают ошибки. И, например, написав вот такой простой код:

void some_complex_class::cleanup() noexcept {
   cleanup_part_one();
   try {
      cleanup_part_two();
      cleanup_part_three();
   }
   catch(...) {} // Just ignore exceptions.
   cleanup_part_four();
}

Можно легко накосячить.

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

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

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

Подобные соображения я уже неоднократно высказывал. В последний раз, вероятно, вот в этом посте двухлетней давности.

Но за это время в C++ никаких подобных механизмов не появилось, а вот потребность в слежении за тем, что я делаю в noexcept-методах и функциях осталась. Скажем, при работе над RESTinio-0.6 выяснилось, что в погоне за функциональностью мы несколько упустили момент с exception-safety, особенно с exception-safety коллбэков, которые отдаются в Asio или еще куда-нибудь. Посему нужно было как-то привести это дело в порядок. Т.е. взять очередной коллбэк, убедиться, что он не бросает наружу исключений. И если это не так, то преобразовать этот коллбэк в noexcept-функцию (например, за счет использования try-catch внутри).

Но при этом хотелось бы иметь проверки со стороны компилятора. Осталось только придумать, как эти проверки получить.

В результате было придумано решение на основе макросов:

#define RESTINIO_ENSURE_NOEXCEPT_CALL(expr) \
   static_assert(noexcept(expr), "this call is expected to be noexcept: " #expr); \
   expr

#define RESTINIO_STATIC_ASSERT_NOEXCEPT(expr) \
   static_assert(noexcept(expr), #expr " is expected to be noexcept" )

#define RESTINIO_STATIC_ASSERT_NOT_NOEXCEPT(expr) \
   static_assert(!noexcept(expr), #expr " is not expected to be noexcept" )

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

templatetypename 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() );
}

Здесь компилятор мне подскажет, что я ошибаюсь в своей реализации, если метод close() не является noexcept.

Второй макрос, RESTINIO_STATIC_ASSERT_NOEXCEPT, помогает один раз проверить что какое-то выражение не бросает исключений, после чего использовать это выражение, например, в цикле. Скажем, вот так:

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 );
            } );
      }
   }
}

Третий макрос, RESTINIO_STATIC_ASSERT_NON_NOEXCEPT, позволяет убедится в том, что try-catch используются в местах, где приходится работать с бросающим исключения кодом. Например:

write_group_t
dequeue_group() noexcept
{
   assert( !m_write_groups.empty() );

   // Move constructor for write_group_t shouldn't throw.
   RESTINIO_STATIC_ASSERT_NOEXCEPT(
         write_group_t{ std::declval<write_group_t>() } );

   write_group_t result{ std::move( m_write_groups.front() ) };

   // We expect that m_write_groups.erase() isn't noexpect
   // and because of that this call should be done via
   // suppress_exceptions_quietly.
   RESTINIO_STATIC_ASSERT_NOT_NOEXCEPT(
         m_write_groups.erase(m_write_groups.begin()) );
   restinio::utils::suppress_exceptions_quietly( [this] {
         m_write_groups.erase( begin( m_write_groups ) );
      } );

   return result;
}

Здесь же, кстати, можно увидеть и еще одно полезное применение второго макроса, RESTINIO_STATIC_ASSERT_NOEXCEPT: с его помощью проверяется, что move-конструктор не бросает исключений.

Вот такой небольшой набор простых макросов, а коэффициент моего спокойного сна при разработке RESTinio-0.6 поднял многократно. По крайней мере пару раз компилятор мне по рукам дал, причем вполне себе по теме. Остается только пожалеть, что в C++ нет noexcept-блока или noexcept-атрибута для выражений, чтобы не приходилось делать подобные велосипеды самостоятельно.

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

void some_complex_class::cleanup() noexcept {
   cleanup_part_one();
   try {
      cleanup_part_two();
      cleanup_part_three();
   }
   catch(...) {} // Just ignore exceptions.
   cleanup_part_four();
}

В этом коде есть ошибка, если cleanup_part_three должна вызываться вне зависимости от того, выскочило ли исключение из cleanup_part_two. Т.е. по хорошему, эта функция должна была бы выглядеть вот так:

void some_complex_class::cleanup() noexcept {
   cleanup_part_one();
   try {
      cleanup_part_two();
   }
   catch(...) {} // Just ignore exceptions.
   try {
      cleanup_part_three();
   }
   catch(...) {} // Just ignore exceptions.
   cleanup_part_four();
}

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

void
close() noexcept
{
   restinio::utils::log_trace_noexcept( m_logger,
      [&]{
         return fmt::format( "[connection:{}] close", connection_id() );
      } );

   // shutdown() and close() should be called regardless of
   // possible exceptions.
   restinio::utils::suppress_exceptions(
      m_logger,
      "connection.socket.shutdown",
      [this] {
         asio_ns::error_code ignored_ec;
         m_socket.shutdown( asio_ns::ip::tcp::socket::shutdown_both, ignored_ec );
      } );
   restinio::utils::suppress_exceptions(
      m_logger,
      "connection.socket.close",
      [this] { m_socket.close(); } );

   restinio::utils::log_trace_noexcept( m_logger,
      [&]{
         return fmt::format(
            "[connection:{}] close: close socket", connection_id() );
      } );

   // Clear stuff.
   RESTINIO_ENSURE_NOEXCEPT_CALL( cancel_timeout_checking() );

   restinio::utils::log_trace_noexcept( m_logger,
      [&]{
         return fmt::format(
            "[connection:{}] close: timer canceled", connection_id() );
      } );

   RESTINIO_ENSURE_NOEXCEPT_CALL( m_response_coordinator.reset() );

   restinio::utils::log_trace_noexcept( m_logger,
      [&]{
         return fmt::format(
            "[connection:{}] close: reset responses data", connection_id() );
      } );

   // Inform state listener if it used.
   m_settings->call_state_listener_suppressing_exceptions(
      [this]() noexcept {
         return connection_state::notice_t{
               this->connection_id(),
               this->m_remote_endpoint,
               connection_state::closed_t{}
            };
      } );
}

2 комментария:

ssko комментирует...

Понимая, что исключения в C++ необходимы, мне кажется, что Вы недостаточно полно оцениваете тот их недостаток, что обработка исключений нелокальна. Семантика panik мне кажется лучше отражает то, что делают исключения.

eao197 комментирует...

@ssko

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