Почему эксепшены (exception) хуже… Будут ли эксепшены хуже? Хуже чего?

—

от автора

Мне что-то часто стал встречаться код в котором вместо обычной логики используются конструкции на основе генерации эксепшенов (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!

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

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

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

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

ссылка на оригинал статьи https://habr.com/ru/articles/1104158/