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

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

В целом, это всё, по большей части, пока эксперименты. Но, вероятно, за подобными штуками будущее языка.

Ссылка на репозиторий с этими экспериментами.

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

Комментарии (2)


  1. bfDeveloper
    29.05.2023 09:37
    +2

    Очередной раз в С++ придумали, как сделать новую фичу на основе уже существующих, вообще для этого не задуманных. Это же почти static exceptions, которые может быть будут в следующих стандартах. И не поймите меня не правильно, мне очень понравилась идея и я даже ею воспользуюсь при необходимости, но почему-то весь С++ состоит из таких вот вещей, что настораживает.

    Есть ли бенчмарки или разбор того, как компилятор это оптимизирует? Теоретически это всё сводится примерно к тому же коду, что был на ифах, но осиливает ли компилятор такие оптимизации? Не вносят ли корутины слишком много накладных расходов?


    1. vamireh
      29.05.2023 09:37

      Есть ли бенчмарки или разбор того, как компилятор это оптимизирует? Теоретически это всё сводится примерно к тому же коду, что был на ифах, но осиливает ли компилятор такие оптимизации? Не вносят ли корутины слишком много накладных расходов?

      Сделать из сопрограмм do-нотацию не нова, вопросы оптимизации компилятором обсуждались на CppCon 2021. Если кратко: при должно усердии можно заставить компилятор всё оптимизировать до уровня обычных if. Но при сборке без оптимизации, конечно, будет тормозить.