Монадическая композиция 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>(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++.
Хочу сказать, что я не распространяю идею избавления от исключений. Каждый найдёт из этого что-то полезное для себя.
Также следует отметить, что представленный в статье код предоставляется только в качестве иллюстрации концепции и не учитывает возможные накладные расходы по памяти или времени выполнения.
В целом, это всё, по большей части, пока эксперименты. Но, вероятно, за подобными штуками будущее языка.
Ссылка на репозиторий с этими экспериментами.
А какие необычные применения сопрограмм знаете вы?