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

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

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

Комментарии (12)


  1. nagibat0r
    05.11.2026 18:15

    Приветствую, коллега! Я с основной мыслью (не использовать исключения как штатный control flow) в данном конкретном примере скорее согласен. Но позволю кое-где поспорить. __cxa_throw не делает longjmp. В обычной Itanium/DWARF-модели он запускает UnwindRaiseException, SJLJ вообще отдельная модель обработки исключений. Двухфазный поиск и unwind действительно есть, но описывать это как длинный longjump в целом некорректно. .eh_frame тоже не содержит какой-то список созданных объектов, которым надо вызвать деструкторы. Там unwind/CFI-информация в первую очередь. Cleanup выполняется с помощью сгенерированных компилятором landing pads и EH-таблиц. И главный момент - table-based exceptions называются zero-cost не просто так. Если исключение не бросается, нормальный путь обычно практически не платит за try/catch, дорогой тут именно фактический throw, потому рассуждение о производительности без частоты ошибок и бенчмарка не доказывает ничего. Из мелочей - std::optional это не разновидность умного указателя. Значение хранится внутри самого optional, причём предложенная в статье замена в показанном виде даже не скомпилится - вызывается старый VariantToStringVisitor, а затем std::optional<std::string> присваивается непосредственно в std::string.

    Ну, то есть, если неподходящий тип конфигурации - это ожидаемый результат парсинга, я бы тоже вернул optional или expected, но из этого не следует, что исключения вообще хуже или что try/catch сам по себе создаёт существенные накладные расходы


    1. rukhi7 Автор
      05.11.2026 18:15

      пока читал ваши замечания придумал еще один аргумент. Функция с эксепшеном заставляет писать try-catch даже если вам не нужно обрабатывать ошибку - ничего не надо делать в случае ошибки - но try-catch вам все равно полноценный придется писать и в этом случае с пустым catch, а с простым if-ом блок else можно элементарно не писать!

      Потом если мне не настолько важна производительность чтобы анализировать какие пути более вероятны, мне тем более не хочется платить ненадежностью кода, который можно сломать просто добавив изменения не туда, потому что в блоке try не видно откуда именно приходит прерывание, оно же ветвление в catch. Фактически в этом случае блок try превращается в блок if с неопределенной границей завершения. А еще надо искать по стеку а какой тип эксепшена писать в параметры catch...


      1. nagibat0r
        05.11.2026 18:15

        Функция с exception не заставляет писать try/catch в месте вызова. Если здесь ошибку обрабатывать нечем - не ловишь её вообще, она уходит выше по стеку. Пустой catch - баг, он просто проглатывает ошибку. И с optional/expected как раз больше ручной работы, их надо явно протаскивать через каждый промежуточный уровень. Exception же это делает автоматически. Аргумент про "в try непонятно, откуда прилетело" это проблема слишком широкого try, а не механизма исключений


    1. rukhi7 Автор
      05.11.2026 18:15

      опечатки поправил! вызывается старый VariantToStringVisitor
      Спасибо!


  1. NeoCode2
    05.11.2026 18:15

    Да, способ явного возврата кода ошибки лично мне больше нравится, он более чистый что-ли. Особенно если в языке присутствует соответствующий синтаксический сахар (в C++ с его шаблонами очень уж громоздко, в Go с его отсутствием сахара для этого случая - очень уж занудно, а вот если язык поддерживает опционалы и варианты на уровне синтаксиса то все достаточно красиво и при этом явно).

    Но все равно существуют "естественные" эксепшены типа деления на ноль. А раз они существуют, то сразу возникает вопрос - что с ними делать? Если бы обработки исключений вообще не было бы, единственный вариант - падение программы, незакрытые/неосвобожденные ресурсы и прочее. Нужно корректно закрыть ресурсы? Значит нужна какая-то обработка, вызвать деструкторы, раскрутить стек, а это уже механизм обработки исключений. И насколько я понимаю, он присутствует в том или ином виде даже в языках без явных исключений. Ну а раз есть механизм, то следующий шаг в этой логике - почему бы не дать программисту пользоваться им явно?


    1. rukhi7 Автор
      05.11.2026 18:15

      эксепшены типа деления на ноль. А раз они существуют, то сразу возникает вопрос - что с ними делать?

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


  1. Politura
    05.11.2026 18:15

    Угу, есть два подхода:

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

    2. Ожидать, что функция вернет то, что от нее попросили. Если она завершилась, то вернула нормальный результат. А уже на самом верху стека сделать один единственный обработчик всех ошибок, который будет смотреть тип эксепшена и что там вернулось в сообщениях эксепшена.

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

    Исключительные ситуации они придуманы для исключительных ситуаций. :) Не для бизнес-логики. Пример: мы хотим проверить, что пользователь есть в базе данных, если да, то использовать какое-то его значение, если нет, то добавить пользователя с дефолтным значением и использовать уже его (пожалуйста, не пинайте ногами, что можно и нужно одной транзакцией сделать, пример специально надуманный). Для этого, мы можем использовать функцию "получить пользователя из базы". Но она бросит эксепшен, если пользователя нет. Просто вызвать ее в try/catch и затем в catch уже добавлять нового пользователя - это неправильное использование исключений. Они не должны использоваться бизнес-логикой, они для ошибок, когда бизнес-логика нарушена и что-то делать дальше нет смысла. Для нашего примера как-раз лучше использовать функцию "получить пользователя из базы, если такой есть, или null если нет". А вот если у нас аутентификация, и не найденый пользователь означает, что дальше ничего делать нельзя, то в этом случае первая функция будет работать сильно лучше.


  1. Warperus
    05.11.2026 18:15

    Предложение для некритичной функции возвращать Optional имеет право на жизнь, и - более то6о - даже используется в других языках программирования.

    К несчастью, возврат полноценной ошибки парсинга xml, например, не ограничивается возвратом ничего в обёртке. Для таких случаев придётся всегда возвращать из функции вариантный контейнер с разными результатами, и на каждом уровне обработки прокидывать этот контейнер выше. Ага, в новый вариантный кон ейнер, возможно, уникальный для каждой функции.

    Естественно, эти усложнения в примере чуть более сложном, чем hello world, не проявляются.

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

    В Java для веб-приложений наоборот эксепшен зачастую ловится уже на слое веб-контроллера, потому что всё просто откатится само по себе. На производительность неудачного вызова по сравнению с удачным (поход в бд, в пару-тройку других сервисов и т.п.) можно не смотреть - это относительно быстрый отказ.

    А в C++ для производитнльных библиотек предусматриваются механизмы без частой генерации эксепшенов, в то время как для библиотек "чтобы было" наоборот можно ограничиться быстрым, понятным и "неправильным" решением.


  1. ProgerMan
    05.11.2026 18:15

    Писал тут же на подобную тему. Считаю, что исключения должны возникать в исключительных ситуация, которые в большинстве случаев требуют особого внимания разработчика, который должен будет это исправить. Бросать исключения где не попадя - это банальная лень. Хуже этого только 100500 try-catch вместо одного глобального, которые в разных местах пытаются среагировать на разные исключения, созданные ранее. Поэтому предпочитаю паттерн Result, который и null считает значением, и в случае ошибки вернёт конкретное сообщение.


    1. rukhi7 Автор
      05.11.2026 18:15

      только это не просто goto, это gotoмежду функциями, то есть longjump, что гораздо хуже чем простой локальный goto.

      longjump, кстати, достаточно, (если не очень) старый. Но как известно все новое это хорошо забытое старое, то есть ваша статья точно не врет!


  1. redfox0
    05.11.2026 18:15

    fn to_mystr<T: ToString>(t: &T) -> String {
        t.to_string()
    }
    
    // или
    trait VariantToStringVisitor {
        fn to_mystr(&self) -> String;
    }
    
    impl<T: ToString> VariantToStringVisitor for T {
        fn to_mystr(&self) -> String {
            self.to_string()
        }
    }
    
    fn main() {
        let _s0 = String::from("привет").to_mystr();
        let _s1 = "привет".to_mystr();
        let _s2 = 123.to_mystr();
        let _s3 = 3.14.to_mystr();
    }
    


  1. roudakov
    05.11.2026 18:15

    Главное достоинство исключений в языках типа C++ - в связке с RAII они обеспечивают автоматическое освобождение ресурсов при раскрутке стека: не нужно вручную прописывать очистку на каждом пути ошибки. Но это работает только для ресурсов, инкапсулированных в объекты. Сырые ресурсы и ресурсы в языках с GC - остаются на совести программиста.

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