can_throw или не can_throw?


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

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 22

      +2
      У Вас как-то все сделано однобоко — заточено на погашении исключения. Нигде в коде не увидел у вас работы с объектом ошибки. Думаю это не верный подход.
        +2

        Так ведь и статья не о том, как обрабатывать исключения. А о том, как при чтении кода быстро понять, что следует вызывать только внутри try. Ну и о том, как получить хотя бы некоторую помощь в этом со стороны компилятора.


        Поэтому в статье нет ни одного реального примера try и catch. Хотя о том, что catch-и с разными действиями в проекте присутствуют в статье упоминается.

        +1
        К сожалению, никто не мешает написать метод, не принимающий can_throw, но бросающий исключение.
        Мне кажется, было бы проще обязать всех ставить к-нить макросы вокруг коллбэков, вроде
        BEGIN_EXCEPTION_HANDLER {
        // work
        } END_EXCEPTION_HANDLER
          0

          Гораздо проще, имхо, обязать всех не передавать callback-и в Asio напрямую, а вызывать специальный метод для создания обертки вокруг callback-а (а сами прикладные действия отдавать в этот метод посредством лямбды). Что, собственно, у нас и происходит.


          Но т.к. мы не можем с помощью компилятора проверить, что все callback-и создаются именно таким образом (а писать какие-то плагины для компилятора или делать собственные анализаторы на базе clang-а в наши планы не входит), то захотелось иметь дополнительную степень безопасности. Если так можно сказать.


          Так же и с макросами BEGIN_EXCEPTION_HANDLER/END_EXCEPTION_HANDLER. Обязать их расставлять можно. А вот как автоматически контролировать выполнение этого требования?

            0
            Мне кажется, выцепить глазом отсутствие макросов намного проще, особенно если взаимодействие с asio вынесено в файлы отдельных типов (как в mvc — модель отдельно, контроллер отдельно… asio отдельно). А как глазами выцепить, что в функции нужен лишний параметр, я даже не представляю
              0
              А как глазами выцепить, что в функции нужен лишний параметр, я даже не представляю

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

                0
                Не понял насчет статической типизации. Ну вот например есть такая функция
                optional<S> parse(json) {
                    S result;
                    if (!json.contains("id")) 
                        return nullopt;
                    result.id = json.get<int>("id");
                    if (!json.contains("value"))
                        return nullopt;
                    result.value = std::stol(json.get<string>("value"));
                    if (!json.contains("type"))
                        return nullopt;
                    result.type = json.get<string>("type");
                    return result;
                }


                Нужно ли в него вставлять этот дополнительный параметр?
                  0

                  Э… Вы ставите меня в тупик. Я не понимаю, что это за функция и какое отношение она имеет к описанному в статье.


                  Могу предположить, что вам нужно вызвать такую функцию из какого-то callback-а, который отдается в Asio. Если так, то все будет зависеть от того, что еще делается в callback-е.


                  Например, у нас это могло бы выглядеть так:


                  m_connection.async_read_some(asio::buffer(m_data),
                     with<const asio::error_code &, std::size_t>().make_handler(
                        [this](can_throw_t can_throw,
                           const asio::error_code & ec,
                           std::size_t bytes_transferred)
                        {
                           if(!ec) {
                              m_data_size = bytes_transferred;
                              auto json = try_extract_payload();
                              auto data = parse(json);
                           }
                        });

                  То, в принципе, в parse необязательно добавлять еще один аргумент. Т.к. parse уже вызывается в контексте, где try/catch есть.


                  Однако, если вы все-таки в parse добавите параметр can_throw, то даже если вы (или тот, кто будет дописывать код после вас) вдруг напишите async_read_some без создания специального врапера:


                  m_connection.async_read_some(asio::buffer(m_data),
                     [this](const asio::error_code & ec,
                           std::size_t bytes_transferred)
                     {
                        if(!ec) {
                           m_data_size = bytes_transferred;
                           auto json = try_extract_payload();
                           auto data = parse(json);
                        }
                     });

                  То компилятор даст вам по рукам.

                    +1
                    Уу, тоесть у вас то, добавлять гуард к функции или не добавлять, еще зависит от того, из какого контекста функция вызывается? Тогда уж точно макросы проще
                      0
                      Уу, тоесть у вас то, добавлять гуард к функции или не добавлять, еще зависит от того, из какого контекста функция вызывается?

                      Так вся статья была посвящена тому, как контролировать функции, вызывающиеся в специфических контекстах.

          0
          Можно сказать, что у вас логически два типа кода — современный с эксепшионами и легаси. Вы смешали два типа кода и тепер просите компилятор контролировать границу перехода. Но для компилятора раздела на современный код и легаси нет и он вам тут нетпомогает. Решение этой проблемы — создание обверти легаси API. Блаодаря обвертке вы как раз и не сможете выстрелить себе в ногу при всем желании. :)
            +1

            Asio — это вовсе не легаси. И Asio сам может выбрасывать исключения. Но вот Asio понятия не имеет, что делать с исключениями, которые вылетают из completion-handler-а. Следовательно, исключения из переданных в Asio completion-handler-ов выпускать нельзя.


            Нам приходится использовать чужие библиотеки на C, которые, в принципе, можно назвать легаси. Но заменить которые вот так просто возможности не представляется. Поэтому нам нужно писать callback-и в C++, а вызывать их будут из C. Куда C++ные исключения отдавать нельзя.


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

              +1
              Я использовал описание кода легаси/не легаси как логическое разделение кода. К реальной старости кода это не относится. Я веду к тому, что у вас есть четкие точки перехода между разными типами кода. Так вот защиту нужно не размызвать тонким слоем, а контролироват границу. Например, функция asio, принимающая каллбэк — делаем обвертку в которой оборачиваем пришедший каллбэк своим кодом, ловящим наши эксепшионы. Что мы получаем:
              — нельзя забыть про эксепшионы
              — наш код чистый
              — profit :)
              А в вашем случае вы переложили контроль границы на все места перехода этой границы, и ясное дело что это еще и дублирование кода контроля, в котором можно ошибиться, или вообще не неапсать.
                0
                Например, функция asio, принимающая каллбэк — делаем обвертку в которой оборачиваем пришедший каллбэк своим кодом, ловящим наши эксепшионы.

                Делать обертки вокруг чужих библиотек — так себе занятие. Вот та же Asio. Непростая библиотека с продвинутым API сама по себе. И, по вашему, нам нужно сделать собственные обертки над asio::ip::tcp::socket и такими его методами, как async_read_some/async_write_some?

                  +1
                  Делать обертки вокруг чужих библиотек — так себе занятие. Вот та же Asio. Непростая библиотека с продвинутым API сама по себе. И, по вашему, нам нужно сделать собственные обертки над asio::ip::tcp::socket и такими его методами, как async_read_some/async_write_some?

                  Asio действительно непростая библиотека, и в ней есть даже небольшая поддержка аспектно-ориентированного программирования, сосредоточенная как раз вокруг обработчиков. Так что оборачивать всё совсем не нужно, вызовы обработчиков можно контролировать. И это расширяемо в том плане, что не важно обработчик чего это — события на сокете, таймере или вообще кто-то написал дополнение для asio с асинхронной работой с какими-то другими объектами.

                  Правда, для этого обработчики должны иметь некий пользовательский тип. Но, по-моему, это и так всегда полезно — хоть для какой-то отладки вызова обработчиков, хоть для выделения памяти для максимальной производительности.
                    0
                    Так что оборачивать всё совсем не нужно, вызовы обработчиков можно контролировать.

                    Я не настолько хорошо владею Asio, чтобы понять, откуда берется my_handler в примере по ссылке:


                    class my_handler;
                    
                    template <typename Function>
                    void asio_handler_invoke(Function function, my_handler* context)
                    {
                      context->strand_.dispatch(function);
                    }

                    Это будет указатель на экземпляр, который передается, скажем, в async_read_some? Типа такого:


                    struct my_read_handler {
                      void operator()(const asio::error_code & ec, std::size_t bytes) {...}
                      ...
                    };
                    ...
                    some_connection.async_read_some(asio::buffer(...), my_read_handler{...});
                      +1
                      Это будет указатель на экземпляр, который передается, скажем, в async_read_some?

                      Да. Точнее, конечно, на копию переданного. Он нужен для разрешения перегрузки. А Function заботится о том, чтобы передать в него нужные аргументы (код ошибки, количество прочитанных байтов и т.д.).
                        0

                        Ясно. Спасибо, не знал про эту возможность.

                0
                Но вот Asio понятия не имеет, что делать с исключениями, которые вылетают из completion-handler-а. Следовательно, исключения из переданных в Asio completion-handler-ов выпускать нельзя.

                Asio отнюдь не возражает против исключений из обработчиков, и относится к ним вполне толерантно.

                Другое дело, конечно, что в catch блоке мы окажемся уж слишком оторванными от контекста, и будет сложно правильно среагировать — удалить какие-нибудь сессии/соединения или совершить попытку ещё раз.
                  0
                  Asio отнюдь не возражает против исключений из обработчиков, и относится к ним вполне толерантно.

                  А можно подробнее. Что значит "относится толерантно"?

                    0
                    Ну, всё есть в документации. Т.е. базовую гарантию мы имеем, asio вполне готов к исключениям из обработчиков, дело за пользователем библиотеки.
                      0

                      Теперь понял о чем вы говорите. Значит у нас специфика такая, что если выпущенное из completion-handler-а исключение покинуло Asio и прервало io_context::run, то дело плохо.

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

            Самое читаемое