Исключения являются частью языка C++. Неоднозначной его частью. Кто-то их принципиально не использует. Вот вообще не использует. От слова совсем. Но не мы. Поскольку считаем их весьма полезной штукой, существенно повышающей надежность кода.
К сожалению, далеко не везде исключения можно задействовать. Во-первых, исключения не бесплатны и, во-вторых, не всякий код способен "пережить" возникновение исключений.
Поэтому приходится держать исключения под контролем. Чему, на мой взгляд не сильно способствуют возможности современного C++. Ибо, как мне представляется, родные механизмы языка C++ в этой части находятся в недоразвитом состоянии.
По большому счету, у нас в распоряжении есть только спецификатор noexcept. Штука полезная, конечно, но недостаточная.
В этой статье я попробую рассказать о том, чего нам в очередной раз не хватило в C++, почему нам этого не хватило, и как мы постарались из этого выкрутиться при помощи старой чужой идеи и подручных средств.
Преамбула
В C++ есть спецификатор noexcept. Видя отметку noexcept в декларации функции/метода разработчик может понять, что вызывая эту функцию/метод исключений можно не ждать. Соответственно, используя noexcept функции/методы можно безопасно писать код для контекстов, в которых бросать исключения нельзя (деструкторы классов, операции swap, передаваемые в C-шный код callback-и и т.д.).
Однако, отметка noexcept хорошо видна лишь когда ты изучаешь декларации функций/методов. Но когда есть код, в котором вызывается какая-то функция/метод, то сразу не поймешь, ждать ли здесь исключений или нет. Вот, например:
void some_handler::on_read_result(
const asio::error_code & ec,
std::size_t bytes_transferred)
{
if(!ec)
{
m_data_size = bytes_transferred;
handle_data();
}
else
{...}
}
Не имея перед глазами декларации handle_data
нельзя просто так определить, могут ли тут вылетать наружу исключения или не могут.
Так что спецификатор noexcept решает только первую часть проблемы: позволяет понять при написании кода можно ли вызывать конкретную функцию/метод не ожидая вылета наружу исключения.
Тогда как вторая часть — это убедится в том, бросает или не бросает исключения уже написанный ранее кусок кода, в котором вызываются те или иные методы. И вот тут лично мне не хватает наличия в C++ чего-то вроде noexcept-блока. Я бы хотел написать кусок кода и поместить этот кусок в noexcept-блок. Что-то типа:
void some_handler::on_read_result(
const asio::error_code & ec,
std::size_t bytes_transferred)
{
noexcept
{
if(!ec)
{
m_data_size = bytes_transferred;
handle_data();
}
else
{...}
}
}
А нужен этот блок чтобы получить проверку со стороны компилятора. Если в noexcept-блоке выполняются только noexcept-операции, то все хорошо. Но если какое-то из действий может бросить исключение, то компилятор выдает предупреждение, а лучше ошибку.
К сожалению, такого noexcept-блока в C++ пока нет. А раз нет, то приходится выкручиваться подручными средствами. Об одном таком самодельном средстве уже рассказывалось некоторое время назад. Сегодня же хочется рассказать о другом слепленном на коленке велосипеде, который несколько облегчил жизнь.
Проблема
Итак, есть недавно начатый свежий C++ проект, в котором исключения не только разрешены, но и используются для информирования о неожиданных проблемах. В этом проекте так же широко применяется механизм обратных вызовов (callback-ов).
Прежде всего это callback-и, которые выступают в роли completion-handler-ов для Asio. Выпускать исключения из таких callback-ов нельзя, т.к. Asio эти исключения не ловит и не обрабатывает. Соответственно, вылет исключения из completion-handler-а — это крах приложения.
Так же есть callback-и, которые отдаются в библиотеку на чистом Си. И, соответственно, оттуда так же нельзя выбрасывать исключения.
Поэтому внутри callback-а, который отдается в Asio или в C-шную библиотеку, нужно сделать try/catch, внутри которого будут выполняться нужные приложению действия, а вот выброшенные исключения будут перехватываться:
void some_handler::on_read_result(
const asio::error_code & ec,
std::size_t bytes_transferred)
{
try
{
handle_read_result(ec, bytes_transferred); // Основные действия.
}
catch(...)
{
// Хотя бы просто "проглотить" исключение.
}
}
Решение очевидное, но, к сожалению, ничто не мешает невнимательному (или уставшему) разработчику написать callback без try/catch и вызвать там метод handle_read_result
. И компилятор тут нам ничем не поможет.
И, на мой взгляд, это проблема. Т.к. по мере развития проекта растет вероятность того, что одна из бросающих исключения функция/метод рано или поздно будет вызвана там, где исключения не перехватываются.
Решение в виде маркера can_throw
Решение было найдено в виде специального маркера can_throw, который передается аргументом во все прикладные функции/методы. Поэтому, если функция получает аргумент типа can_throw, то она может бросать исключения. А также вызывать другие функции/методы, которые получают can_throw.
Соответственно, если в каком-то callback-е нам приходится вызывать функцию/метод, которые требуют аргумента can_throw, то нам нужно позаботится о перехвате и обработке исключений.
А позаботится об этом нас заставит сам компилятор, т.к. маркер can_throw нельзя просто так создать и отдать в вызываемую функцию/метод. Т.е. мы не можем написать вот так:
void some_handler::handle_read_result(
can_throw_t can_throw,
const asio::error_code & ec,
std::size_t bytes_transferred)
{
... // Прикладная обработка которая может бросать исключения.
}
void some_handler::on_read_result(
const asio::error_code & ec,
std::size_t bytes_transferred)
{
// Вот так быть не должно!
handle_read_result(can_throw_t{}, ec, bytes_transferred);
}
Для того, чтобы экземпляры can_throw нельзя было создавать просто так был применен следующий подход:
class can_throw_t
{
friend class exception_handling_context_t;
can_throw_t() noexcept = default;
public:
~can_throw_t() noexcept = default;
can_throw_t( const can_throw_t & ) noexcept = default;
can_throw_t( can_throw_t && ) noexcept = default;
can_throw_t &
operator=( const can_throw_t & ) noexcept = default;
can_throw_t &
operator=( can_throw_t && ) noexcept = default;
};
Т.е. кто угодно может копировать и перемещать экземпляры типа can_throw_t
, но вот создавать эти экземпляры "могут не только лишь все" (с). Для того, чтобы получить экземпляр can_throw_t
следует сперва создать экземпляр типа exception_handling_context_t
:
class exception_handling_context_t
{
public:
can_throw_t
make_can_throw_marker() const noexcept { return {}; }
};
а затем воспользоваться методом make_can_throw_marker()
void some_handler::on_read_result(
const asio::error_code & ec,
std::size_t bytes_transferred)
{
try
{
exception_handling_context_t ctx;
handle_read_result(ctx.make_can_throw_marker(), ec, bytes_transferred);
}
catch(...)
{}
}
Да, при этом ничто не запрещает создавать экземпляры exception_handling_context_t
и без использования блоков try/catch. И можно было бы попробовать сделать более железобетонное решение. Например, функцию wrap_throwing_action
, которая бы получала на вход лямбду, а внутри имела бы блок try, внутри которого бы лямбда и вызывалась. Что-то вроде:
class can_throw_t
{
// Разрешаем создание can_throw только внутри
// шаблонной функции wrap_throwing_action.
template<typename Lambda>
friend void wrap_throwing_action(Lambda &&);
can_throw_t() noexcept = default;
public:
... // Все как показано выше.
};
template< typename Lambda >
void wrap_throwing_action(Lambda && lambda)
{
try
{
lambda(can_throw_t{});
}
catch(...)
{}
}
Можно было бы и так.
Но пока мы ограничились именно показанными выше тривиальными реализациями can_throw_t
и exception_handling_context_t
.
Отчасти потому, что у нас callback-и и так создаются посредством специальных шаблонных функций, которые оборачивают лямбды несколькими слоями вспомогательных оберток, в том числе там есть и блок try.
Отчасти потому, что какие-то функции/методы нужно вызывать не только из callback-ов, но и из конструкторов объектов. А в конструкторах исключения разрешены, посему и создавать внутри тела конструктора дополнительный try нет смысла. Гораздо проще внутри конструктора объявить временный exception_handling_context_t
и вызывать нужную функцию:
some_handler::some_handler(
std::vector<std::byte> initial_data,
std::size_t initial_data_size)
: m_data{std::move(initial_data)}
, m_data_size{initial_data_size}
{
exception_handling_context_t ctx;
handle_data(ctx.make_can_throw_marker());
}
...
void some_handler::handle_read_result(
can_throw_t can_throw,
const asio::error_code & ec,
std::size_t bytes_transferred)
{
if(!ec)
{
m_data_size = bytes_transferred;
handle_data(can_throw);
}
else
{
...
}
}
...
void some_handler::handle_data(can_throw_t)
{
... // Прикладная обработка данных.
}
Отчасти еще и потому, что для разных ситуаций нужны разные действия в catch: где-то проблемы логируются, где-то "проглатываются" (но при этом из callback-а возвращается код ошибки, а не положительный результат). Попытка запихнуть эти особенности обработки исключений в wrap_throwing_action
только усложнила бы реализацию wrap_throwing_action
.
Общие впечатления
Общие впечатления от использования описанного выше решения в течении двух месяцев клепания килотонн нового кода практически в режиме "без выходных и проходных" хорошие. Коэффициент спокойного сна сильно повысился. Как и обозримость кода: сразу видно, где исключения могут и будут вылетать. Причем это видно не только в местах декларации функций/методов, но и, что более важно в данном случае, в местах вызова функций/методов.
Однако, есть два момента, которые обязательно нужно подчеркнуть и которые не позволяют декларировать данное решение в качестве хоть сколько-нибудь универсального.
Во-первых, это увеличение объема кода за счет маркеров can_throw. Т.е., с одной стороны, глядя на код сразу видишь, кому разрешено бросать исключения. Но, с другой стороны, во многих функциях/методах появляется дополнительный параметр. И требуется некоторая привычка, чтобы не обращать на него внимание, если хочется разобраться с тем, что и как делает метод.
Во-вторых, накладные расходы на передачу маркера can_throw вниз по стеку вызовов не оценивались. В нашем конкретном случае такие накладные расходы, если они и есть, роли не играют. Т.к. callback-и, в которых can_throw создаются, вызываются ну максимум несколько десятков тысяч раз в секунду. И передача экземпляров can_throw внутри callback-а — это просто копейки по сравнению с выполняемой callback-ами прикладной работой (не говоря уже о стоимости операций, приводящих к вызову callback-ов).
Но вот если бы функции с маркерами can_throw стали бы вызываться миллионы раз в секунду, то накладные расходы на can_throw стоило бы оценить. Возможно, современные оптимизирующие компиляторы просто повыбрасывали бы передачу can_throw из генерируемого кода. Но сделали бы они это или нет, а если бы сделали то во всех ли случаях, — это все нужно проверять на практике.
Поэтому, как минимум, два вышеозначенных момента нужно иметь в виду тем, кто захочет применить подход с маркерами can_throw в своем коде.
Заключение
В данной статье я попытался поделится нашим свежим опытом и решением, которое несколько облегчило нам жизнь, при разработке нового кода на C++.
Но само это решение не было придумано нами. Насколько я помню, подобный подход описывался на каком-то из форумов (вроде бы это был RSDN) лет эдак 15 назад. Так что мы здесь ничего не изобретали, а лишь вспомнили про то, что кто-то придумал много лет назад.
Конечно же, было бы лучше иметь более продвинутые средства контроля за выбросом исключений в С++. Тогда бы не пришлось прибегать к велосипедам типа can_throw. Но пока есть лишь то, что есть :( И для повышения степени доверия к коду приходится собирать на коленке собственные велосипеды.