Монадическая композиция Expected в C++

от автора

Продолжение статьи На грани между 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>(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++.

Хочу сказать, что я не распространяю идею избавления от исключений. Каждый найдёт из этого что-то полезное для себя.
Также следует отметить, что представленный в статье код предоставляется только в качестве иллюстрации концепции и не учитывает возможные накладные расходы по памяти или времени выполнения.

В целом, это всё, по большей части, пока эксперименты. Но, вероятно, за подобными штуками будущее языка.
Ссылка на репозиторий с этими экспериментами: https://github.com/broly/Erxpected

А какие необычные применения сопрограмм знаете вы?


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


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *