Почему я предпочитаю исключения, а не значения ошибок

от автора


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

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

Давайте рассмотрим пример, в котором применено защищённое от ошибок целочисленное деление:

int safeDiv(int a, int b) {    if (b == 0)       throw Div0(); // Исключения передаются особым образом    return a / b; // Теперь-то всё абсолютно безопасно, ведь так? }

Новые языки программирования склонны применять сообщения об ошибках в функциональном стиле и кодировать ошибки в возвращаемый тип. Например, Go кодирует ошибку в возвращаемый тип при помощи кортежа (res, err), а Rust возвращает Result<T, E> — тип-сумму результата и ошибки.

Даже в более старых языках наподобие C++ значения ошибок сегодня включают в стандартную библиотеку при помощи std::expected:

// Ошибка стала частью сигнатуры функции  std::expected<int, Div0> safeDiv(int a, int b) {    if (b == 0)       return std::unexpected(Div0());    return a / b; }

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

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

На самом деле, чаще всего всё происходит наоборот.

Например, по моему опыту, в коде на Rust встречается больше вызовов unwrap, чем мне хочется. Проблема здесь в том, что простое развёртывание результатов приводит к вылету программы на ошибках, которые в случае использования исключений могли бы быть видимыми для пользователя сообщениями об ошибках.

▍ Так было ли использование типов результатов ошибкой?

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

Кроме того, они обеспечивают более высокую производительность; в рассмотренном ниже примере реализация на C++ с использованием исключений примерно в четыре раза быстрее, чем на Rust.

▍ С исключениями гораздо проще работать

Стоит учитывать, что я пишу эту статью с точки зрения человека, работающего с серверным кодом, значительный объём которого хранится в памяти. Это значит, что я отдаю приоритет выполнению серверного процесса, и скорее смирюсь со сбоем одного запроса, чем с вылетом всего приложения. Чтобы соблюдать SLA аптайма, мне нужно правильно обрабатывать любые сбои. Да, даже те, при которых не справляется абстрактная машина языков программирования и начинают проявляться ограничения реальной системы. Память небесконечна, CPU не могут обрабатывать все целые числа, а Деда Мороза не существует. Хоть моя точка зрения определённо не полностью объективна, я считаю, что от подобного подхода могут выиграть и другие системы. Даже в случае фронтенд-приложений я бы предпочёл увидеть диалоговое окно ошибки, чем вылет и потерю состояния.

В случае серверных программ у нас есть хороший способ обработки ошибок: отправка сообщения об ошибке клиенту, а если это не срабатывает, то закрытие соединения. Да, даже в ситуациях out-of-memory и SIGFPE, которое возникает при делении на ноль, можно отправлять сообщения об ошибках, а не создавать панику для всей системы. Да, закрытие всей программы — это «безопасно», но не особо продуктивно и к тому же может представлять собой вектор для DoS-атак.

Исключения чрезвычайно всё упрощают. Где бы мы ни находились, пусть даже на глубине в сотню стеков вызовов в далёком уголке программы, при обнаружении ошибки мы выбрасываем исключение. После этого всю сложную работу берёт на себя раскрутка стека. В Linux это обычно происходит в libgcc, определяющей способ раскрутки при помощи двоичного файла с DWARF eh_frame. Красота раскрутки заключается в том, что вызываются все деструкторы, удаляются все дескрипторы ресурсов, а все удерживаемые блокировки снимаются. А лучше всего то, что вся магия происходит за кулисами, ведь компилятор и так генерирует правильную информацию о раскрутке. [Если, конечно, вы не занимаетесь реализацией JIT и написанием компилятора, но это уже тема для отдельного поста.]

Так что простого блока try-catch в начале нашей обработки соединения будет достаточно для правильной обработки ошибки и передачи сообщения клиенту.

▍ Бойлерплейт

Сравним это с ошибками в функциональном стиле, когда обработка ошибок выполняется вручную и очень утомительно. Необходимо явным образом проверять, является ли значение возврата ошибкой, и пересылать его. Нужно снова и снова писать бойлерплейтные if (err) return err, которые замусоривают код.

Давайте вкратце исследуем метрики кода двух примерно одинаковых проектов баз данных. CockroachDB — проект на Go с явно обрабатываемыми ошибками:

$ rg 'if err != nil' | wc -l 24570

Почти 25 тысяч путей обработки ошибок!

Большинство этих ошибок — это артефакты повторяющегося стиля отчётности с пересылкой, но есть и достаточно много случаев, просто используемых для log.Fatal(). На мой взгляд, это не особо удобно.

CedarDB — наш проект, в котором используются исключения C++:

$ rg 'catch \(' | wc -l 140

В сто с лишним раз меньше кода!

При поверхностном изучении кажется, что большинство из проверок находится или тестах, или в коде подчистки, где мы не хотим использовать вложенные исключения, которые вызывали бы std::terminate.

Разумеется, существуют решения, позволяющие снизить объём бойлерплейта. В Rust можно подсластить некрасивый синтаксис макросом ?. Он скроет все повторяющиеся проверки, но всю работу вам всё равно придётся делать. Кроме того, теперь ваша функция возвращает коды ошибок, то есть вам придётся изменить публичный интерфейс и рекурсивно менять все функции, вызывающие эту функцию. Вместо того, чтобы работать в своём уголке, ваша дополнительная проверка теперь распространяется на половину кодовой базы. С другой стороны, всегда есть искушение добавить немного unwrap(). Вас эта змея точно не укусит.

▍ Системные ошибки

Что, если я скажу вам, что большинство программ уже обрабатывает гораздо меньше ошибок, чем следует? Вернитесь к примеру с safeDiv из начала статьи и попробуйте поделить -232 на -1. Получится -232, но это число не умещается в диапазоне int. Ой-ёй.

Как же гарантировать, что ваши программы не вылетят? Добавлять возврат ошибки каждый раз, когда функция делает assert? Но и это ещё не всё: распределение памяти может сбоить, стек и арифметические могут переполняться. Без выбрасывания исключений такие состояния отказа часто просто оказываются незамеченными. Вызываете, казалось бы, невинную функцию? Переполнение стека! Ой, ваш сервер свалился. Складываете два значения? Паника в Rust! Присваиваете значение временному объекту? Поздоровайтесь с OOM killer! В любой части потока кода, которая управляется пользователем (а чем он не управляет?), могут возникнуть подобные ошибки.

Что касается распределения памяти, если вы работает с Go, то вам не повезло, ведь у него есть сборщик мусора. На Rust у меня больше надежды, ведь работа по интеграции с ядром постепенно заставляет его расти и создавать более надёжные интерфейсы, не приводящие к панике, например, Box::try_new(). Но это потребует рефакторинга всего написанного кода.

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

▍ Исключения позволяют создавать более качественные сообщения об ошибках

Можно подумать, что при более явной обработке ошибок ближе к источнику ошибок мы наверняка будем получать более качественные сообщения об ошибках. Но мой опыт снова говорит об обратном: значения возврата при ошибках часто содержат мало информации и приводят к созданию плохих сообщений об ошибках. Классический пример — это системные вызовы, обычно отвечающие стандартам C. Возвращаемое значение часто оказывается просто кодом ошибки -1, а настоящая причина по каким-то причинам передаётся как побочный канал через errno. Разобраться в истинной причине сбоев часто невозможно по одному лишь коду ошибки, для этого требуется существенный объём контекста вокруг вызывающего кода. Вы получили EINVAL. Что делать дальше? strerror сообщает: «Invalid argument». Замечательно! Вероятно, система точно знает, что пошло не так, но отказывается сообщать подробности.

То есть errno сам по себе не особо полезен. Для получения нужных сообщений об ошибках вам нужен системный вызов в качестве контекста, но даже в этом случае ошибки остаются непонятными. Возьмём для примера write(2), который может возвращать EINVAL в случае, когда «object which is unsuitable for writing» или в случае невыровненных буферов. Но как определить, какой из этих двух вариантов верен?

Новые языки справляются с этим немного лучше, но ошибки Rust тоже попадают в ловушку error kind. Мы парсим где-нибудь int, и пользователю выводится IntErrorKind::InvalidDigit. Я тут выполняю парсинг многомегабайтных CSV, а вы говорите мне, что «в строке найдена недопустимая цифра». Меня это должно устроить? Разумеется, есть крейты вне std наподобие anyhow, которые делают это лучше. Но по моему опыту, значения возврата из-за ошибок только мотивируют к появлению плохих ошибок. Для создания правильного сообщения об ошибке необходимо иметь существенный объём контекста на пути возникновения ошибки, который не поместится в простой код ошибки. Вместо этого вам, вероятно, придётся распределять динамическую ошибку, как и в случае с исключениями. Для отладки может также понадобиться добавление обратной трассировки. И так вы получите исключения, только с лишними шагами!

▍ Исключения обеспечивают более высокую производительность

У исключений есть и ещё один туз в рукаве: при успешном выполнении они никак не снижают производительность, ведь мы разделяем поток управления. Значения возврата из-за ошибок перемешивают ошибку и путь успешного выполнения и всегда требуют проверок и ветвлений для обработки ошибок. За это приходится платить снижением производительности для каждого результата. Давайте рассмотрим пример с рекурсивным вычислением чисел Фибоначчи. Чтобы избежать переполнений, мы будем сообщать об ошибке при большой глубине вычислений.

В примерах ниже используется код на C++, что позволяет точнее сравнить две версии:

struct invalid_value { std::string reason; };  unsigned do_fib_throws(unsigned n, unsigned max_depth) {    if (!max_depth) throw invalid_value(std::to_string(n) + " exceeds max_depth");    if (n <= 2) return 1;    return do_fib_throws(n - 2, max_depth - 1) + do_fib_throws(n - 1, max_depth - 1); }  std::expected<unsigned, invalid_value> do_fib_expected(unsigned n, unsigned max_depth) {    if (!max_depth) return std::unexpected<invalid_value>(std::to_string(n) + " exceeds max_depth");    if (n <= 2) return 1;    auto n2 = do_fib_expected(n - 2, max_depth - 1);    if (!n2) return std::unexpected(n2.error());    auto n1 = do_fib_expected(n - 1, max_depth - 1);    if (!n1) return std::unexpected(n1.error());    return *n1 + *n2; }

Версия с expected чуть длиннее, но, по сути, это ведь один и тот же код, правда? Значит, и выполняться он должен одинаково!

Сравнение производительности исключений и значений ошибок.

С исключениями C++ десять тысяч итераций с n=15 выполняются за 7,7 мс. Со значениями возврата std::expected они выполняются за 37 мс — почти пятикратный рост времени исполнения! Можете убедиться сами: Quick Bench. Версия на Rust чуть быстрее, чем версия на C++ с expected, но всё равно в четыре раза медленнее, чем версия, выбрасывающая исключения! Даже если мы пожертвуем ради производительности строкой сообщения об ошибке и будем возвращать в expected только код ошибки, исключения всё равно вдвое быстрее. Да, разумеется, функции обычно бывают чуть больше (если вы не строго следуете стилю чистого кода; тогда вас, вероятно, не заботит производительность). Но в реальном коде я тоже наблюдал существенное замедление.

▍ Почему возвращаемые значения ошибок настолько медленнее?

Очевидная причина, то есть проверки на ошибки и ветвление, ничего не объясняет: следует ожидать, что CPU быстро научится прогнозировать ветвления, то есть, по сути, они будут «бесплатными», ведь так? Это прячет от нас тот факт, что эти проверки втихомолку потребляют обычно невидимые ресурсы CPU: кэш команд, кэш микроопераций, буфер истории ветвлений, буфер переупорядочивания и так далее. Так как выполнение почти любой функции может закончиться сбоем (помните, что распределения и математика могут и будут завершаться сбоем), проверка всевозможных ошибок занимает приличное количество ресурсов, которые нельзя использовать под полезный код. Исключения обрабатываются по совершенно иному пути исполнения кода, обычно выделенному в «холодных» частях, что уже даёт им большое преимущество.

Ещё одно отличие заключается в том, что вместо возврата одного int мы возвращаем «толстый» объект результата. Это делает вызов гораздо более затратным, потому что теперь нам нужно перемещать значения в стеке, что требует дополнительной подготовки. В случае успеха нам также нужно преобразовывать «толстый» результат в реальное значение. Это может помешать компиляторным оптимизациям, в частности, оптимизации хвостовых вызовов. В общем случае, пересылка ошибок в функциональном стиле требует больше регистров и места в стеке, что в свою очередь приводит к снижению степени встраивания кода. Разумеется, можно обойти эти проблемы, распределяя ошибки где-нибудь на странице, например, в локальной памяти потока. И именно так поступают исключения!

▍ Выбрасывание исключений когда-то было ужасно медленным

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

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

Однако раскрутка стека раньше обладала серьёзной проблемой: она синхронизировалась глобально. Чтобы найти местоположения кадра стека в двоичном файле, нам нужно смотреть в глобальную таблицу символов, которая затем используется для поиска информации о раскрутке для текущей функции. Как уже говорилось, эти символы находятся в разделе .eh_frame формата DWARF. Эта информация обычно статична, генерируется во время компиляции и обычно не требует синхронизации. Однако программы также могут загружать код динамически при помощи dlopen, например, для загрузки плагинов или для JIT-компиляции.

Проблема здесь заключается в том, что нам нужна изменяемая структура данных, в которой можно динамически обновлять информацию раскрутки для нового кода. Для защиты такой структуры libgcc просто создаёт мьютекс вокруг этого кода. Даже на машине с сотней ядер ни одно исключение не может быть выброшено одновременно. При высокой concurrency и множестве одновременных клиентов это может стать узким местом. К счастью, теперь в libgcc есть функциональность, позволяющая выполнять весь процесс раскрутки без блокировок. В простых случаях, например, когда не загружаются динамические кадры стека, блокировки использовать не нужно. В случае с динамическими кадрами всё чуть сложнее, но Томас Нойманн закоммитил реализацию B-дерева с оптимистичным связыванием блокировок для таких динамических блоков кода.

Однако даже при всём при этом раскрутка всё равно затратнее, чем должна быть. Для раскрутки стека таблицы eh_frame DWARF, по сути, интерпретируются, то есть здесь можно ещё многое улучшить. Уже существуют исследовательские предложения, например, Reliable and Fast DWARF-Based Stack Unwinding для компиляции таблиц раскрутки в нативный код, что должно быть на порядок величин быстрее, чем современные реализации исключений.

▍ В заключение

На мой взгляд, у исключений есть множество преимуществ по сравнению с значениями возврата из-за ошибок:

  • Исключения обеспечивают разделение задач, отделяя ошибочный путь.
  • Результаты при использовании кодов ошибок могут скрывать ошибки системного уровня, например, out-of-memory или переполнения.
  • По умолчанию благодаря исключениям можно легко предоставить контекст первопричины.
  • Код, использующий исключения, может работать быстрее, чем код со встроенными возвратами.

Я имею большой опыт работы с языком C++, позволяющим практически что угодно, поэтому мне непонятно, почему более новые языки наподобие Rust или Go не позволяют применять исключения. Хотя исключения можно использовать неверно, значения ошибок в функциональном стиле определённо не являются универсальным решением. Если вам необходимо определить контекст сообщения об ошибке, вам нужны исключения, так что у вас должна быть возможность их применять.

Telegram-канал со скидками, розыгрышами призов и новостями IT 💻


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