Мне что‑то часто стал встречаться код в котором вместо обычной логики используются конструкции на основе генерации эксепшенов (exception). Как оценить такой код? Может нам стоит стараться использовать эксепшены как можно чаще?

Я решил проанализировать реальные примеры, вот например

https://github.com/openbmc/dbus‑sensors/blob/ce706f061f2b6877e953fa0ad8f5200e99f15854/src/VariantVisitors.hpp#L53 и посмотреть на сколько код с эксепшенами лучше или хуже некоторой альтернативной версии кода. Заодно посмотрим есть ли альтернатива коду с эксепшенами.


Код c эксепшенами

Вот тут у нас одна из функций которая генерирует эксепшен в случае ошибки:

https://github.com/openbmc/dbus‑sensors/blob/ce706f061f2b6877e953fa0ad8f5200e99f15854/src/VariantVisitors.hpp#L53

она достаточно короткая и вполне подходит для нашего примера:

struct VariantToStringVisitor
{
    template <typename T>
    std::string operator()(const T& t) const
    {
        if constexpr (std::is_same_v<T, std::string>)
        {
            return t;
        }
        else if constexpr (std::is_arithmetic_v<T>)
        {
            return std::to_string(t);
        }
        throw std::invalid_argument(
            "Cannot translate type " +
            boost::typeindex::type_id<T>().pretty_name() + " to string");
    }
};

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

https://github.com/openbmc/dbus‑sensors/blob/ce706f061f2b6877e953fa0ad8f5200e99f15854/src/psu/PSUSensorMain.cpp#L821

этот код тоже достаточно простой:

                try
                {
                    labelTypeName = std::visit(VariantToStringVisitor(),
                                               findCustomName->second);
                }
                catch (const std::invalid_argument&)
                {
                    lg2::error("Unable to parse '{NAME}'", "NAME", keyName);
                    continue;
                }

Что мы видим? Фактически мы создали функцию которая возвращает разные типы в зависимости от того произошла ошибка в ходе выполнения функции или нет! То есть функция либо возвращает валидное значение либо она возвращает… Стоп! Но тут, мне кажется, не очень корректно будет говорить что функция что‑то возвращает! Механика обработки‑генерации эксепшенов построена на том, что эксепшен прерывает работу функции. Прервать работу функции и вернуться куда‑то вверх по стеку в подходящий catch блок задача отнюдь не тривиальная, а главное очень не быстро выполнимая.

Конструкция throw это вызов пары специальных функций библиотеки ран‑тайма! Если вам интересно вы можете посмотреть здесь очень краткое описание одной из возможных реализаций процесса.

Конструкцияthrow (по крайней мере в Linux/Unix ) на уровне ассемблерного кода разворачивается в последовательный вызов двух функций рантайма: __cxa_allocate_exception и __cxa_throw.

Вторая функция (__cxa_throw) делает «длинный безусловный переход» longjump, но далеко не сразу. Ее выполнение разделено на две очень важные фазы.


Что на самом деле происходит внутри __cxa_throw

Если бы она делала безусловный переход мгновенно, программа бы ломалась, так как деструкторы локальных переменных не успевали бы вызваться. Полная картина выглядит так:

  1. Вызов__cxa_allocate_exception: выделяется память под создаваемый объект исключения.

  2. Вызов__cxa_throw: в нее передается указатель на объект эксепшена, информация о его типе и указатель на деструктор этого объекта.

  3. Фаза 1 внутри__cxa_throw(Поиск / Search Phase):

    • Функция запускает процесс Scavenging (поиск по таблицамeh_frame).

    • Она виртуально «шагает» вверх по кадрам стека, сопоставляя адреса возврата с таблицами компилятора.

    • Цель этой фазы: Просто найти блокcatch, который согласится принять этот тип исключения. На этом этапе деструкторы локальных переменных еще не вызываются, стек физически не уничтожается. Если блок не найден — прямо здесь будет вызван std::terminate().

  4. Фаза 2 внутри __cxa_throw(Раскрутка / Cleanup Phase):

    • Как только блокcatchуспешно найден на Фазе 1,__cxa_throwзапускает раскрутку стека.

    • Она снова идет по тем же кадрам стека, но теперь для каждого кадра она находит в таблице список созданных объектов и принудительно вызывает их деструкторы.

  5. Безусловный переход (Прыжок):

    • Только после того, как все деструкторы в уничтожаемых кадрах стека отработали,__cxa_throwвосстанавливает регистры процессора под состояние целевой функции.

    • Она совершает тот самый безусловный переход (jump) на адрес так называемого Landing Pad — физического начала нашего блокаcatch.

С точки зрения последовательности исполнения нашего кода, throw — это действительно просто вызов функции. Для вызывающего контекста функция __cxa_throw никогда не возвращает управление стандартным путем (через инструкцию RET). Вместо этого она перехватывает поток управления процессора, наводит порядок в памяти, который сломан как раз ее вызовом и ожидаемым нештатным завершением функции и «телепортирует» выполнение программы в catch‑блок.

Обратите внимание в нашем коде вызова в блоке

catch (const std::invalid_argument&)

стоит оператор continue; то есть это штатная ветка какого‑то анализа. Представляете какую задержку на этой ветке создает генерация эксепшена: создание алокация структуры эксепшена /поиск этого catch‑блока / анализ стека на предмет объектов, которым надо вызвать деструкторы!

Но может быть код вызова у нас в случае с эксепшенами получается проще чем при использовании классической логики???

Давайте попробуем написать‑реализовать ту же функциональность без использования эксепшенов!

Код БЕЗ эксепшенов

Оказывается в этом случае достаточно поменять возвращаемое значение функции которая раньше генерировала эксепшен на std::optional!

struct VariantToOptionalStringVisitor
{
    template <typename T>
    std::optional<std::string> operator()(const T& t) const
    {
        using V = std::remove_cvref_t<T>;
        if constexpr (std::is_same_v<V, std::string>)
        {
            return t;
        }
        else if constexpr (std::is_arithmetic_v<V>)
        {
            return std::to_string(t);
        }
        return std::nullopt;
    }
};

Представляете! Вместо генерации эксепшена можно просто написать return std::nullopt; Как же так? Может тогда что‑то плохое случится с вызовом нашей функции? Давайте посмотрим как придется изменить вызов ведь мы все таки поменяли возвращаемое значение std::optional<std::string> вместо просто std::string!

аuto tmpStr = std::visit(VariantToOptionalStringVisitor(),
                         findCustomName→second);
	if(tmpStr)
    {
        labelTypeName =  tmpStr;
    }
    else
    {
        lg2::error("Unable to parse '{NAME}'", "NAME", keyName);
        continue;
    }

тут нам понадобилась временная переменная — разновидность умного указателя, но мне кажется это лучше чем целая структура под Эксепшен при очевидном упрощении логики. Ведь блок try сам по себе создает некоторую неопределенность и риск того что кто‑то добавит какой‑то код в этот блок и тогда не будет понятно, а где же в этом блоке try происходит собственно ветвление, на какой строчке внутри этого блока с добавленным кодом.

Мне кажется очевидно что этот код без обработки эксепшена получился абсолютно наглядным и поэтому надежным. Если вы хоть немного знаете С++ вы не можете перепутать ветку с обработкой ошибки от ветки с обработкой валидного значения!

В общем для меня большой вопрос зачем в обычный код парсинга, анализа данных какой‑то конфигурации, например, нужно обязательно впихнуть генерацию и обработку эксепшенов. Может у кого то есть версии зачем это все таки делают? Это мода что ли какая‑то или может это задание преподавателя студентам прокачать работу с эксепшенами?