Я принадлежу к числу тех, кто считает, что исключения -- это гораздо более предпочтительный способ информирования о проблемах, чем возвращаемые коды ошибок. Надеюсь на то, что при дальнейшей эволюции С++ найдут способ сделать исключения предсказуемее и еще дешевле. После чего поводов отказываться от исключений, даже при низкоуровневом программировании, не останется.
Многие жалуются, что в коде не видно, откуда исключения могут вылететь. ИМХО, это просто из-за недостатка опыта. Если заставить себя думать о том, что исключения могут вылететь откуда угодно, то со временем к этому привыкаешь. И начинаешь понимать, что на самом деле гораздо более важно иметь возможность увидеть, откуда исключение вылететь не может.
В 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, используется для того, чтобы оформлять вызовы (или операции), в которых в принципе не должны случаться исключения. Например:
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() ); } |
Здесь компилятор мне подскажет, что я ошибаюсь в своей реализации, если метод 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 комментария:
Понимая, что исключения в C++ необходимы, мне кажется, что Вы недостаточно полно оцениваете тот их недостаток, что обработка исключений нелокальна. Семантика panik мне кажется лучше отражает то, что делают исключения.
@ssko
Это как раз достоинство исключений. Если я не знаю, как справиться с проблемой здесь и сейчас, то вообще ничего не делаю, проблема летит дальше. Если знаю, то обрабатываю по месту. При этом, в отличии от кодов ошибок, исключение просто так не проглотить и не потерять.
Отправить комментарий