Как стать автором
Обновить

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

Чего-то похожего можно добиться, используя просто std::expected<T,std::error_condition>. Все pro-пункты применимы, как и два из трех contra (или boost::system::error_code, который уже содержит в себе возможность прицепить как раз source_location). Оно менее универсально, чем ваш вариант - два уровня иерархии вместо произвольного, и нельзя добавить доп. поля в какой-то тип ошибки - но зато готовое решение.

Еще одна интересная особенность таких type erased решений - проблемы с работой между несколькими DSO (то есть при передаче expected из вызываемой функции, находящейся в одной DSO, в вызывающую функцию из другого DSO). Нужно убедиться, что typeid() и ваш кастомный GetTypeId() выдают идентичный результат в обоих DSO для любого типа, который используется в ошибке (т.е. все типы ошибок для вашего варианта и все типы категорий для std::error_condition/error_code), а это может быть нетривиальненько. Я в это наступил, когда использовал asio в одном DLL, а проверял результат (boost::error_code, который использует typeid() для сравнений категорий) на равенство конкретной ошибке в другом DLL.

А что думаете вы об этом?

Думаю, что вот этот тезис:

а также освобождает программиста от рутинных задач, таких как явное указание noexcept в API своего проекта

нуждается в раскрытии и объяснении. Если у вас в проекте исключения разрешены, то каким образом std::expected позволяет не писать noexcept? Какое отношение std::expected в проекте с исключениями вообще имеет к тому, может ли вылететь из функции/метода исключение или нет?

Я подразумевал именно отключенные исключения в проекте. Собственно и написал эту статью, чтобы рассказать о приближении сего инструмента к исключениям: новомодный стандартный expected, но с привычным похожим интерфейсом :)

Так если исключения отключены, то зачем расставлять noexcept? У вас же тогда, по сути, все функции/методы noexcept.

Стандартная библиотека осуществляет ряд оптимизаций на основе того объявлена функция noexcept или нет. И здесь утверждается что флаг -fno-except и noexcept для gcc и clang это разные вещи: https://stackoverflow.com/questions/10787766/when-should-i-really-use-noexcept#comment121480642_67134262

Стандартная библиотека осуществляет ряд оптимизаций на основе того объявлена функция noexcept или нет

Да, например, если у типа есть небросающий move operator.
Только вот у move operator (как и у move constructor) зафиксированный прототип и вкорячить туда описанный в статье Expected вряд ли возможно.

Так что ваше замечание верно по сути, но не имеет отношения к моему вопросу. А автор статьи, к сожалению, отморозился :(

Это и имел в виду: отключить исключения в пользу использования expected, и, как следствие, нет нужды в расставлении noexcept. То есть некое сравнение использования двух механизмов, в том числе и по визуальному виду кода, если бы пришлось выбрать один из них.

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

  • разработчик приложил усилия для того, чтобы сделать функцию/метод реально не бросающей исключения или же

  • полностью избежать исключений не получается, но выброшенное исключение оставляет программу в некорректном состоянии и самое лучшее, что можно сделать -- это вызвать std::terminate. Например, к таким случаям относятся деструкторы и swap. Т.е. стараются делать их небросающими в принципе, но если это не удалось и исключение вылетело, то справиться с последствиями не представляется возможным.

Плюс к тому, исключения (когда они разрешены) должны использоваться в действительно исключительных случаях. Ну, скажем, когда они бросаются в 0.1% случаев. В противном случае исключения обеспечат серьезную просадку производительности. Поэтому expected более чем уместен и при включенных исключениях. При этом функция, возвращающая expected вовсе не должна помечаться как noexcept, т.к. в ней могут возникать исключения (в том числе и при конструировании возвращаемого expected).

В общем, очень странная мотивация делать expected для того, чтобы не расставлять noexcept.

Всё же исключение это в первую очередь радикальное изменение хода выполнения программы. Поэтому если для вас исключение это "разворачивание стека и проблема производительности", и, скорее всего, в обработчике вы просто проверяете значение ошибки с минимумом изменений в control flow, то, скорее всего, вы что-то спроектировали не так.
std::expected это скорее ещё одно решение когда пространство значений возвращаемого результата не может включать специальных значений для сигнализации о чём-то плохом. Но это никак не альтернатива исключениям в принципе.

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

std::expected вообще спорная вещь и я искренне не понимаю всего восторга вокруг этой штуки. Ну и меня смущают утверждения типа expected быстрее по перформансу чем exception.

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

Я на std::expected смотрю как на симпатичную замену std::pair<T*, E*> в возвращаемых значениях функции т.к. optional несет недостаточно информации о причинах ошибки.

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

Есть вопросы к лишним зависимостям на новый GOD-file errors.hpp, от которого теперь зависит весь проект.

exception-style объявления и зависимости мне нравятся все таки больше именованных интов.

struct MyCoolException : public std::runtime_error {

public:

using std::runtime_error::runtime_error;

}

Это, при том, что раскрутка стэка и весь оверхед эксепшенов происходит только в момент этого самого exception

А сам exception, если действительно применять их по назначению, это чуть менее 0,01% всего остального времени исполнения.

Это, при том, что раскрутка стэка и весь оверхед эксепшенов происходит только в момент этого самого exception

Это в теории, на практике ряд оптимизаций становятся невозможными из-за исключений. И правильно расставленные noexcept уменьшают количество генерируемого кода, см. например доклад “There Are No Zero-cost Abstractions”, там в частности рассматривается ассебмлер для "сырого" указателя и unique_ptr и демострирует как noexcept помогает бороться с генерацией лишних инструкций: https://youtu.be/rHIkrotSwcc?t=1252

У функций, которые действительно не имеют ошибочного варианта выполнения, noexcept добавлять полюбому желательно, чтобы избежать картинки из доклада (интересно, какой-нибудь статический анализатор умеет такие подсказки давать?). У функций, которые имеют ошибочный вариант, исключения можно только заменить на std::expected или ему подобное, и соответственно в вызывающем коде в месте вызова (если делать вариант с обработкой ошибки путем выдачи "наверх") будет та же пессимизация. В этом смысле это не исключения пессимизируют код, а сам факт наличия ошибочного состояния, и его в ноль заоптимизировать не получится. Хотя в конкретном примере из доклада вариант с исключениями на "нормальном" пути выполнения мог быть теоретически заоптимизирован до аналогичного коду без исключений (если бы компилятор понимал, что такое destroying move и знал, что unique_ptr именно такой).

std::expected хуже исключения в том смысле, что он добавляет оверхед на проверку тега в месте вызова, в то время как исключения - в нормальном пути выполнения не имеют вообще никакого оверхеда. std::expected лучше, чем std::pair, т.к. 1) хранят что-то одно - соответственно имеют меньший размер, и 2) могут быть, и должны бы быть оптимизированы компилятором - например, тег "ошибка" предлагалось вынести в флаг процессора (carry, например) - тогда проверка на ошибочность сводится к единственной операции jc или типа того (что, конечно, хуже, чем ничего, т.к. давит на предсказатель переходов). Но притом std::expected (при хорошей поддержке компилятора) дают минимальный оверхед на нормальном пути, и гарантируют детерминированный, и более чем вменяемый, овехед на "исключительном" пути.

Теоретически, компилятор может совсем заморочиться и обеспечить честный zero-overhead в нормальном пути через небольшое увеличение оверхеда на исключительном пути. Если предполагать, что он и так имеет спец. поддержку для этого типа и спецABI для таких функций (CF у нас в ABI сейчас не входит...), то можно было бы ее расширить до магии типа того: в месте вызова заменяем вызова типа

call function_returning_expected
jc exceptional_branch

на

call function_returning_expected
nop dword ptr [EAX + offset exceptional_branch - $]

или тому подобный длинный магический nop. А в каждой функции, возвращающей expected, выделить ветки return, возвращающие ошибочное значение, в отдельный блок, и там проверять вызывающую функцию на наличие этого магического nop и переходить по адресу, внедренному в него (возможно, придется для этого внедрить проверки на ошибку, которых в функции не было - но это не страшно, т.к. нас есть магический nop). Тогда на нормальном пути мы получим "идеальный" вариант выполнения, как с коде с исключениями, а на исключительном - немного лишнего кода, в котором самое неприятное - это переход по вычисляемому адресу (впрочем, в коде с jc было бы неправильное предсказание ветвления, что тоже недешево).

Спасибо за развернутый ответ, но по итогу улучшение std::expected привело нас к функционалу exception...

Так как на следующем уровне, мы возвращаем эксепшен через несколько слоёв стака вверх, т.к. указывать std::expected<std::expected<T,E1>,E2> это ну не очень, и обрабатывать ошибки будем в месте вызова. А еще корректно закрывать стакфрейм вызывая деструкторы и ловить только ожидаемые ошибки, а остальные перебрасывать...

В общем, это разные механики, там где подходит expected всегда подойдёт exception, но не наоборот.

А так ли дороги exception? Лично я просто ожидаю, когда компилятор начнёт самостоятельно раскидывать noexcept в ветки методов, как делает это с старым inline, а ребята которые расставляют noexcept ради бафа... перестанут.

Вообще мне кажется, что в стандарт это ввели не ради перфа, а для переработки (обертки над) старыми апи которые складывали ошибку в errno() или аналог и никак не требовали делать проверку значения. Возможно C++-enthusiast-ы опять ничего не поняли.

expected<expected> - это, конечно, совершенно не то, что надо. Предполагается скорее вариант expected<T1, E>, выше уровнем expected<T2, E> и тд. Ошибку - пробрасываем наверх в общем случае, а значения обрабатываем. По типу, как сделано в некоторых других языках (тот же Rust с его Result и синтаксическим сахаром в виде знака вопроса). Да, предполагается минимальная обработка ошибок после каждого вызова, хотя бы в виде `if (!result)) return result;` - C-style, блин (хотя бы не `if (!result) goto error5;`). Есть предложения типа P2505 для "упрощения" такого подхода, но пока что-то не очень красиво все. Было бы красиво с предложением типа P0779, но такой тип подхода завернули, вроде бы.

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

Минус использования std::pair в возвращаемых значениях функций в том, что пара явно никак не указывает на то, что одно из ее значений - это возвращаемый результат, а другое - код ошибки. std::pair может являться просто агрегатором пары возвращаемых значений и в языках типа Python такое распространено, особенно, когда одно из значений в вызывающей функции используется опционально. С std::expected ситуация более очевидная. Меня больше удивляет, почему std::expected не появился раньше. Например, в микроконтроллерах часто не используют исключения.

Иногда... их там нет.

Как же я настрадался от atoi который возвращает 0, если парсинг не удался.

Исключения в микроконтроллерах не используют зачастую потому что:

А) Их там нет :)

Б) Эмбедеры никогда не видели исключения и банально не знают, что их можно применять. Или не умеют. Или не хотят осваивать их. Причин много, в основном культурные.

Для меня кстати когда-то стало откровением, что в большинстве контроллеров нормально работает std::string.

Но как я был удивлен узнав, что на ESP32 работают эксепшены и работает std::thread и std::async и, что можно выкинуть RTOS из жизни, ух... непередаваемо)

Заголовок ввёл в заблуждение. Ожидал рассказ про то, когда использовать первое вместо второго (мб на каком-то конкретном примере). Статья кул, но немножко раздосадован.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории