can_throw или не can_throw?

98hw2jglfeukbgz0uwbq6oxnuki.png

Исключения являются частью языка 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.

Соответственно, если в каком-то 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
    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 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. Но пока есть лишь то, что есть :(И для повышения степени доверия к коду приходится собирать на коленке собственные велосипеды.

© Habrahabr.ru