Продолжение статьи На грани между exceptions и std::expected.
Здесь речь пойдёт о трюке, который ещё больше имитирует код под исключения C++ (а так же в какой-то степени уподобляется некоторым функциональным языкам). Реализован такой трюк будет при помощи описанного в предыдущей статье типа Expected и сопрограмм.
Expected из предыдущей статьи представляет собой шаблонный контейнер с явным типом значения и со стёртым типом ошибки. В статье была представлена возможность примитивного способа отлова ошибок с помощью условного оператора, но это всё ещё совсем далеко от реальных исключений. И в этой статье я решил попробовать продвинуться ещё дальше в этом направлении.
Сопрограммы для Expected
Для осуществления этой идеи нам понадобятся корутины из C++20. Корутины дают возможность прерывания выполнения функции и последующего его возобновления. Хотя в данном кейсе интересует только прерывание, причём с некоторым эффектом, о котором будет рассказано позже. В контексте уподобления механизму исключений, необходимо понять как сделать аналог throw, который позволит преждевременно выйти из функции, а также пролететь по стеку обратно, чтобы вернуться к точке, где начались вызовы к expected-функциям.
Итак, в типе Expected у нас может быть либо полезное значение, которое мы ожидаем получить, либо ошибка, которая может возникнуть в процессе выполнения. Когда возникает ошибка, мы хотим, чтобы выполнение программы вернулось на ту точку, где начались вычисления, и передало ошибку обработчику. В зависимости от ситуации, ошибка может быть обработана, чтобы продолжить выполнение программы, или же программа может быть завершена (std::terminate) в случае необработанной ошибки в Expected.
Всё, что нам надо сделать - это усыпить функцию в момент получения ошибки, а так же передать эту ошибку другому Expected (кадром ниже в стеке). И сделать это можно, немного дописав класс Expected.
Для начала объявим специальный вложенный тип-обещание promise_type прямо в Expected:
template<typename T> struct Expected : public ExpectedBase { struct promise_type { std::suspend_never initial_suspend() noexcept { return {}; } std::suspend_never final_suspend() noexcept { return {}; } void unhandled_exception() noexcept {} Expected<T> get_return_object() { return (Expected<T>)(Expected<T>::HandleType::from_promise(*this)); } Expected<T>* Exp; void return_value(Expected<T> InExpected) { *Exp = InExpected; } }; using HandleType = std::coroutine_handle<promise_type>; ... };
А теперь поясню, что же тут теперь будет происходить:
initial_suspendиfinal_suspendвозвращаютsuspend_never, значит нас не интересует приостановка в начале и конце сопрограммы.get_return_objectвозвращает самExpected(возвращаемый тип сопрограммы).В обещании мы держим указатель на созданный Expected, чтобы взаимодействовать с ним из самого обещания.
При возврате значения (которое, кстати, так же является Expected) из сопрограммы, мы хотим, чтобы оно сохранялось в наш текущий Expected (копирование состояния).
Так же, в базовом классе мы задаём
HandleTypeкак алиас для хэндла сопрограммы-expected. Это нужно для самогоpromise_type, а так же для конструктора, чтобы инициализироваться из promise.
Ещё необходимо определить тройку специальных методов для самого Expected, а так же добавить специальный конструктор и поле хэндла сопрограммы:
bool await_ready() { return !HasError(); } void await_suspend(std::coroutine_handle<promise_type> Handle) { // Тот самый эффект, который влияет на сопроргамму кадром в стеке ниже - пропагирование ошибки Handle.promise().Exp->ErrHolder = ErrHolder->Copy(); Handle.destroy(); // Мы уничтожаем хэндл корутины, поскольку после усыпления она не должна вновь просыпаться никогда } Expected<T> await_resume() { return *this; } HandleType Handle; Expected(std::coroutine_handle<promise_type> InHandle) { Handle = InHandle; Handle.promise().Exp = this; }
Реализация данного интерфейса гласит:
await_readyвозвращает false, если есть ошибка. Как только ошибка возникает, сопрограмма усыпляется.В
await_suspend(при выходе из сопрограммы), мы сохраняем ошибку в хэндл той сопрограммы, в которую мы выходим из текущей сопрограммы.await_resume- функция, которая предоставляет возвращаемое значение при завершении сопрограммы.И конструктор, который необходим при инициализации сопрограммы из обещания. Так же мы подсовываем обещанию указатель на текущий Expected (
this), чтобы обещание могло им манипулировать.
Усыпление (suspension) сопрограммы влечет за собой передачу ошибки в другую сопрограмму, в которой был вызван co_await, чтобы спровоцировать так же и её усыпление. И так происходит рекурсивно. Ручное уничтожение (вызов метода destroy) говорит о том, что мы не собираемся вызывать метод resume у хэндла, вместо этого мы хотим полностью уничтожить весь фрейм, вместе с вызовом всех деструкторов объектов, находящихся в нём, чтобы сопрограмма не висела в памяти по каким-то причинам.
Примеры
Весь этот минимальный код даёт нам возможность использовать сопрограммы с Expected как монады, но, так скажем, в do-нотации (аналогия с некоторыми функциональными языками). Давайте посмотрим, что из этого может получиться.
Во-первых, мы можем работать с expected так же, как и раньше:
Expected<int> Ok() { return 1; } Expected<int> Fail() { return Unexpected(ErMathError("math error!")); } Expected<int> Test() { int A = Ok(); auto B = Fail(); if (B.HasError()) return Unexpected(B.GetError()); return A + *B; }
А вот взгляните на этот пример:
Expected<int> Test() { int A = MaybeOkA(); int B = MaybeOkB(); int C = MaybeOkC(); return A + B + C; }
Здесь в строках 3, 4, 5 может произойти ошибка и программа аварийно завершится, так как ошибка нигде не обработана.
Чтобы обработать каждую потенциальную ошибку, нам бы пришлось делать что-то вроде этого:
Expected<int> Test() { auto A = MaybeOkA(); if (!A.HasError()) { auto B = MaybeOkB(); if (!B.HasError()) { auto C = MaybeOkC(); if (!C.HasError()) { return *A + *B + *C; } else return Unexpected(C.GetError()); } else return Unexpected(B.GetError()); } else return Unexpected(A.GetError()); return *A + *B + *C; } // Или этого... Expected<int> Test1() { auto A = MaybeOkA(); if (A.HasError()) return Unexpected(A.GetError()); auto B = MaybeOkB(); if (B.HasError()) return Unexpected(B.GetError()); auto C = MaybeOkC(); if (C.HasError()) return Unexpected(C.GetError()); return *A + *B + *C; }
Выглядит довольно boilerplate. Но присыпав сопрограммами, мы получаем вот такой, уже презентабельный, код:
Expected<int> Test() { int A = co_await MaybeOkA(); int B = co_await MaybeOkB(); int C = co_await MaybeOkC(); co_return A + B + C; }
Теперь проверка и выход произойдут за нас автоматически с помощью await_readyна любом co_await, где функция вернула ошибку.
Do-нотация в Haskell чем-то похожа на это. В блоке происходят вычисления с помощью привязки монад (посредством оператора
<-), и, если результат вычисления какой-либо монады будетNothing, то весь блок вычисляется какNothing.
Позволю себе небольшую прихоть так же сделать макрос, который позволит добавить чуть больше синтаксического сахара в этот код:
#define expect *co_await Expected<int> Test() { co_return expect MaybeOkA() + expect MaybeOkB() + expect MaybeOkC(); }
И по сути:
Мы ставим
co_await, если хотим получать всегда значение из expected (без ошибки), а при ошибке, предварительно прервав текущую сопрограмму, отправить ошибку вExpectedпо стеку ниже, который в свою очередь так же передаст свою эстафетную палочку следующему, и так далее, пока не закончится прерывание всех сопрограмм. На мой взгляд, это весьма удобно, хотя и непривычно по сравнению с исключениями. Но мы ведь не просто так дочитали до этого момента? Так или иначе это всё расширяет сознание и весьма интересно.Мы не ставим
co_await, если хотим решить что делать с потенциальной ошибкой вExpectedсами. Например мы хотим "отловить" ошибку так, будто бы это исключение, что-то с ней сделать (вывести что-то на экран), и запустить её в полёт дальше по стеку вниз.
// "Отлов" ошибок Expected<int> Test() { int Result1 = co_await MaybeOkA(); int Result2 = co_await MaybeOkB(); Expected<int> Result3 = MaybeOkC(); // Вычисляем MaybeOkC без оператора co_await if (auto Error = Result3.Catch<ErMathError()) { std::cout << "Error occured! " << Error->What() << std::endl; co_await Result3; // rethrow } int Result4 = co_await Ok(); co_return Result1 + Result2 + *Result3 + Result4; }
Внимательный читатель заметит, что вместо ключевого слова
return, в некоторых местах используетсяco_return. И это необходимость сопрограмм. Если в теле функции есть хоть одно ключевое слово, делающее из функции сопрограмму (co_await,co_yield,co_return), то обычныйreturnуже будет невалидной конструкцией. Однако вы по прежнему можете использоватьreturn, если функция не является сопрограммой. Разницы в данном случае почти не будет:Expectedлибо возвращается явно (посредствомreturn), либо это происходит при помощи встроенного механизма сопрограммы (используя специальные функции сопрограмм C++return_value,await_resume)
Но всё это ещё немного не то, хочется чего-то более близкого к try/catch...
Обработка ошибок с помощью функторов
И вот он, ещё один примечательный способ отлавливать ошибки. Заключается он в вызове функций, первый аргумент которых, является подходящим подмножеством ошибки. Иначе говоря - эмуляция pattern matching. И это очень похоже на конструкцию try/catch.
Давайте посмотрим как это может выглядеть:
auto Result = Try( [&] () -> Expected<int> // Функтор, действия в котором, могут привести к ошибке { int A = co_await MaybeA(); int B = co_await MaybeB(); co_return A + B; }, // Далее перечисляются отлавливаемые ошибки (в первых аргументах функторов) по категориям [&](const ErMathError& Err) { std::cout << "This is math error: " << Err->What(); return 12; }, [&](const ErRuntimeError& Err) { std::cout << "This is other runtime error: " << Err->What(); return 13; });
Реализовать такой паттерн можно с помощью характеристик функции и выражения-свёртки. Функция Try должна представлять собой мэтчер, который в качестве первого параметра принимает функтор, внутри которого может произойти ошибка. Далее, перечисляются функторы, которые должны принимать в качестве первого аргумента обрабатываемые ошибки. Если нашелся функтор, который может обработать такую ошибку, то происходит его вызов, после чего мэтчер должен завершить свою работу.
Реализация такого обработчика ошибок:
template<typename TryCallable, typename... CatchCallables> auto Try(TryCallable&& InTryCallable, CatchCallables&&... InCatchCallables) { // Выполняем функтор с expected и записываем во временный результат auto Result = InTryCallable(); if (!Result.HasError()) return Result; bool Caught = false; // Объявляем шаблонную лямбду, которой будет передаваться тип ошибки, // а в качестве аргумента - функтор-обработчик auto Handler = [&] <typename ErrType> (auto&& CatchCallable) { if (!Caught) { // Пытаемся достать ошибку по категории if (auto V = Result.Catch<std::decay_t<ErrType>>()) { Caught = true; // Если удаётся, вызываем обработчик и передаём значение в Result Result = CatchCallable(*V); } } }; // Выражение свёртки позволяет вызвать сразу много шаблонизированных лямбд последовательно, // а так же передать им шаблонный аргумент (Handler.operator()<typename TFunctionTraits<CatchCallables>::Type0>(std::forward<CatchCallables>(InCatchCallables)), ...); // возвращаем expected с уже новым состоянием. // Но если ни один из обработчиков не смог поймать ошибку, возвращаем старое состояние return Result; }
TFunctionTraits из примера выше
// Список аргументов функции template<typename...> struct TFunctionTraits_Args; // Одна из специлизаций (интересует только первый аргумент) template<typename T0> struct TFunctionTraits_Args<T0> { using Type0 = T0; }; // Шаблонная мета-функция, выдающая информацию о функции template <typename T> struct TFunctionTraits : TFunctionTraits<decltype(&T::operator())> {}; template <typename ClassType, typename R, typename... Args> struct TFunctionTraits<R(ClassType::*)(Args...) const> : TFunctionTraits_Args<Args...> { using ReturnType = R; static constexpr auto Arity = sizeof...(Args); };
Заключение
Объединив сопрограммы и expected мы получаем паттерн, который позволяет писать код, который очень приближен к использованию исключений. Возможность делать такие вещи на уровне языка - несомненно большой шаг в сторону развития C++.
Хочу сказать, что я не распространяю идею избавления от исключений. Каждый найдёт из этого что-то полезное для себя.
Также следует отметить, что представленный в статье код предоставляется только в качестве иллюстрации концепции и не учитывает возможные накладные расходы по памяти или времени выполнения.
В целом, это всё, по большей части, пока эксперименты. Но, вероятно, за подобными штуками будущее языка.
Ссылка на репозиторий с этими экспериментами.
А какие необычные применения сопрограмм знаете вы?
