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

На грани между exceptions и std::expected

Уровень сложностиСредний
Время на прочтение8 мин
Количество просмотров8.1K

Посмотрев на новый тип из грядущего стандарта под названием std::expected я пришел к интересному на мой взгляд мнению, что можно немного переосмыслить его суть и сделать несколько ближе к исключениям.

В данной статье хочу немного рассказать о небольшом исследовании реализации expected, в которой используется стирание типа ошибки.

Немного об std::expected

Данный тип задумывался как один из вариантов обработки ошибок. В отличие от исключений, он хорош тем, что даёт дополнительный выигрыш в производительности, благодаря отсутствию необходимости разворачивания стека, а также освобождает программиста от рутинных задач, таких как явное указание noexcept в API своего проекта. Он представляет собой своего рода золотую середину между исключениями (уже привычным механизмом в C++) и возвращаемыми кодами ошибки (как принято делать в языке C).

Данный тип в общем случае представляет собой контейнер в стиле std::optional, но с двумя параметрами шаблона: T (тип, значение которого содержится в контейнере) и E (тип ошибки, содержащейся в этом контейнере).

std::expected<std::string, int> foo = "hello";

При использовании этого типа мы можем либо попробовать достать оттуда значение, либо запросить о том какое там значение ошибки. Обычно это выглядит следующим образом:

enum class MathError : unsigned char
{
    ZeroDivision,
    NegativeNotAllowed
};

std::expected<int, MathError> Bar(int a, int b)
{
  if (b == 0)
    return std::unexpected(MathError::ZeroDivision);
  if (a < 0 || b < 0)
    return std::unexpected(MathError::NegativeNotAllowed);
  
  return a / b;
}

int main()
{
  std::expected<int, MathError> foo = Bar(1, 3);
  
  if (foo.has_value())
  {
    std::cout << *foo;
  }
  else if (foo.error() == MathError::ZeroDivision)
  {
    std::cout << "Divided by zero";
  } else if (foo.error() == MathError::NegativeNotAllowed)
  {
    std::cout << "Negative numbers not allowed";
  }
}

То есть мы знаем заранее какой тип ошибки у нас может быть, и в данном примере это MathError. А что если под Bar подразумевается довольно неоднозначная логика? Может быть арифметическая ошибка, а может системная. Первое, что приходит на ум - это сделать enum с разными значениями ошибки, таким образом мы привязываемся явно к этому перечислению. Однако, можно ли "скрыть" этот тип и определять ошибку динамически во время выполнения программы?

Стирание типа ошибки

Стирание типа (type erasure) - паттерн в языке C++, который основан на использовании шаблонов и полиморфизма. Что же это дает нам? Он позволит сохранять ошибку какого угодно типа в объекте типа expected в любой момент времени, при этом сигнатура объявления переменной сокращается до одного шаблонного аргумента, например expected<int>.

Общий интерфейс класса при этом выглядел бы примерно так:

template<typename T>
class Expected
{
public:

  template<typename E>
  Expected(Unexpected<E> Unexp)
  {
    SetError(Unexp.Error);
  }

  Expected(T Value)
  {
    StoredValue = Value;
  }

  bool HasError() const;

  template<typename E>
  void SetError(E&& Error);

  template<typename E>
  const E* GetError() const;

  bool HasValue() const;

  inline operator T() const;

protected:

  std::optional<T> StoredValue;

  // Сюда будет помещаться сама ошибка
  std::unique_ptr<ErrorHolderBase> StoredError;
};

// Структура, необходимая для передачи ошибки в Expected
template<typename E>
struct Unexpected
{
  Unexpected(E InError)
  {
    Error = InError;
  }
  E Error;
};

Теперь к реализации с помощью стирания типа. И первое, что мы делаем это объявляем базовый класс хранителя ошибки.

struct ErrorHolderBase
{
  // Возвращает текст ошибки
  virtual std::string GetErrorText() const = 0;

  // Возвращает указатель на хранимую ошибку
  virtual void* GetErrorPtr() const = 0;

  virtual ~ErrorHolderBase() {}
};

Возникает вопрос, а что нам даст указатель на ошибку стёртый до void*? Чтобы решить его мы можем использовать идентификаторы типов, как это реализовано в std::any, и тут можно пойти двумя путями: использовать RTTI с typeid, либо отказаться от RTTI и сделать самописный счётчик уникальных идентификаторов типов, чтобы уметь различать типы ошибок друг от друга. Второй вариант мне больше импонирует, так как я работаю в проекте, в котором, по соглашению, RTTI отключено (привет, UnrealEngine). В общем буду пользоваться своим велосипедом и приведу в спойлере пример реализации такого счётчика:

Custom TypeId
struct TypeIdCnt
{
  template<typename>
  static uint32 GetUniqueId()
  {
    static const int32 TypeId = NewTypeId();
    return TypeId;
  }

private:
  static uint32 NewTypeId()
  {
    // thread-safe
    static std::atomic<uint32> CurrentId = 0;
    return CurrentId++;
  }
};

template<typename T>
static uint32 GetTypeId()
{
  return TypeIdCnt::GetUniqueId<T>();
}

Суть такова: каждый новый тип T создаёт новый инстанс функции, что увеличивает счётчик.

Мы будем сохранять так же и идентификатор типа в хранилище ошибки. Теперь код будет выглядеть вот так:

struct ErrorHolderBase
{
  // Возвращает текст ошибки
  virtual std::string GetErrorText() const = 0;

  // Возвращает указатель на хранимую ошибку
  virtual void* GetErrorPtr() const = 0;

  // Возвращает либо указатель на ошибку, либо nullptr, если тип не соответствует
  virtual void* RetrieveError(uint32 ErrorTypeId) const = 0;

  virtual ~ErrorHolderBase() {}

  std::set<uint32> Bases;
};

template<typename ErrorType>
struct ErrorHolder : ErrorHolderBase
{
  ErrorHolder(ErrorType InError)
  {
    Error = InError;
  }

  virtual std::string GetErrorText() const
  {
    // для каждого типа ошибки можно перегрузить функцию error_to_str для получения текстового представления
    return error_to_str(*Error)
  }

  virtual void* GetErrorPtr() const
  {
    return reinterpret_cast<void*>(&Error);
  }

  virtual void* RetrieveError(uint32 ErrorTypeId) const
  {
    if (GetTypeId<ErrorType>() == ErrorTypeId)
      return GetErrorPtr();
    return nullptr;
  }

  ErrorType Error;
};

Мы можем получать ошибку из контейнера, зная её тип. Однако, это означает, что в контейнере может быть любой тип ошибки, а получить ошибку мы сможем только если предположим правильный тип (указывать в качестве шаблонного параметра). Это ограничивает возможность классификации ошибок по категориям. Такой подход подходит для базовых типов, строк и других типов, которые не требуют категоризации ошибок. Хотелось бы добавить дополнительный метод под названием Catch, который эмулировал бы механизм исключений (в некоторой степени), позволяя извлекать ошибки из нового варианта expected по категориям (хранить наследника и ловить по родителю). Пример кода может выглядеть следующим образом:

struct BaseError{};
struct MathError : BaseError{};
struct SystemError : BaseError{};

expected<int> ValueOrError = unexpected(MathError());

if (auto Error = ValueOrError.Catch<BaseError>())
{
  // ...
}

Чтобы решить данную задачу, мы можем сохранять идентификаторы классов ошибок непосредственно во всю их иерархию. Подобный трюк делается в реализации dyn_cast в Clang: https://llvm.org/doxygen/ExtensibleRTTI_8h_source.html

Для этого мы воспользуемся паттерном "декоратор", который будет добавлять наследнику идентификатор его родителя. Таким образом мы можем получить идентификаторы всех типов в иерархии:

template<typename T>
struct DeriveError : T
{
  using T::T;
  
  // Данная функция собирает идентификаторы из всей иерархии рекурсивно
  static std::set<uint32> GetBaseIds()
  {
  	std::set<uint32> Bases = { GetTypeId<T>() };
      // Так же спрашиваем идентификаторы у родителя
  	Bases.merge(T::GetBaseIds());
  	return Bases;
  }

};

Так же, необходим какой-то базовый класс ошибки наподобие std::exception, который хранит как свой идентификатор, так и предоставляет некоторый интерфейс для получения информации об ошибке.

struct ErRuntimeError
{
  ErRuntimeError(const std::string& InMessage)
  {
    Message = InMessage;
  }

  static std::set<uint32> GetBaseIds()
  {
  	return { GetTypeId<ErRuntimeError>() };
  }
  
  std::string What() const
  {
  	if (Message.IsEmpty())
  		return GetErrorType();
  	return GetErrorType() + ": " + Message;
  }
  
  virtual std::string GetErrorType() const
  {
  	return "RuntimeError";
  }
  
  virtual ~ErRuntimeError() = default;
  
protected:
  std::string Message;

};

// Перегрузка для получения текствого представления об ошибке
inline std::string error_to_str(const ErRuntimeError& Error)
{
  return Error.What();
}

Так же имеет смысл завести макрос, которым можно будет создавать новые типы ошибок, чтобы миновать boilerplate-кода:

#define DEFINE_RUNTIME_ERROR(Error, Parent) \
  struct Error : DeriveError<Parent> \
  { \
    using ParentType = DeriveError<Parent>; \
    using ParentType::ParentType; \
    virtual FString GetErrorType() const override \
    { \
      return #Error; \
    } \
  };

Теперь объявления ошибок будут выглядеть таким образом:

// Мат. ошибка
DEFINE_RUNTIME_ERROR(ErMathError, ErRuntimeError);
// Мат. ошибка - деление на ноль
DEFINE_RUNTIME_ERROR(ErZeroDivisionError, ErMathError);
// Ошибка значения
DEFINE_RUNTIME_ERROR(ErValueError, ErRuntimeError);

И сейчас, когда у нас есть иерархия ошибок с идентификаторами, мы можем написать свой вариант Catch для нового expected. При получении ошибки, мы имеем полное право явно прикастовать void* к E*, так как CatchError обязан выдать указатель на ошибку, если переданный идентификатор существует в иерархии, либо вернуть nullptr.

template<typename T>
template<typename E>
const E* Expected<T>::Catch()
{
  const int32 ErrorTypeId = GetTypeId<E>();
  return static_cast<E*>(StoredError->CatchError(ErrorTypeId));
}

А при установке ошибки мы делаем что-то вроде этого:

template<typename T>
template<typename E>
void Expected<T>::SetError(E&& Error)
{
  StoredError = std::make_unique<ErrorHolder<E>>(std::forward(Error));

  if constexpr (std::is_base_of_v<ErRuntimeError, E>)
  {
  	std::set<uint32> Bases = E::GetBaseIds();
  	Bases.add(GetTypeId<E>());
  	StoredError->SetBases(Bases);
  }
}

Здесь создаётся само хранилище ошибки. А далее просто передаются идентификаторы типов всей высшей иерархии ошибки E (включая саму ошибку) в хранилище ошибки.

Вместо вышеупомянутого в статье RetrieveError, теперь мы можем пользоваться методом CatchError нашего полиморфного хранилища ошибки, который прежде чем выдать указатель на ошибку, проверяет, есть ли такой идентификатор типа в сохраненном ранее списке иерархии, либо возвращает nullptr.

void* ErrorHolderBase::CatchError(uint32 ErrorTypeId) const
{
  if (Bases.contains(ErrorTypeId))
    return GetErrorPtr();
  return nullptr;
}

void ErrorHolderBase::SetBases(const std::set<uint32>& InBases)
{
  Bases = InBases;
}

Теперь мы можем проверять содержимое expected в стиле исключений C++:

Expected<int> Bar(int a, int b)
{
  if (b == 0)
    return Unexpected(ErZeroDivisionError("b is Zero"));
  
  if (a < 0 || b < 0)
    return Unexpected(ErNegativeNotAllowed("a < 0 or b < 0"));
  
  return a / b;
}

int main()
{
  Expected<int> foo = Bar(1, 3);

  if (foo.has_value())
  {
    std::cout << *foo;
  }
  else if (auto ZDError = foo.Catch<ErZeroDivisionError>())
  {
    std::cout << ZDError->What();
  }
}

Заключение

Для чего может понадобиться такой вариант expected?

Мои причины использовать expected со стёртым типом ошибки следующие:

  1. Более простая семантика объявления ожидаемых значений. Использование стёртого типа ошибки в expected позволяет сократить сигнатуру объявления переменной до одного шаблонного аргумента. Это улучшает читаемость и понимание кода.

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

  3. Обработка ошибок по категориям. Использование стёртого типа ошибки позволяет обрабатывать ошибки по категориям, что приближает expected к механизму исключений. Это дает гибкость и удобство при обработке различных видов ошибок.

Причины, почему не стоит использовать стёртый тип ошибки:

  1. Отсутствие возможности использовать в compile-time. Динамический полиморфизм не даёт возможность использовать expected во время компиляции.

  2. Неопределенный тип ошибки затрудняет понимание источника ошибки. Однако на мой взгляд это легко можно решить с помощью дополнительных средств языка, например добавить в хранилище ошибки так же и информацию о месте в исходном коде, где возникла эта ошибка: std::source_location https://en.cppreference.com/w/cpp/utility/source_location

  3. Потребление большего объема памяти. Использование стёртого типа ошибки в expected требует хранения идентификаторов классов предков ошибки, что может привести к увеличению потребления памяти.

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

Ссылка на репозиторий: https://github.com/broly/Erxpected

Вторая часть: Монадическая композиция Expected в C++

Теги:
Хабы:
Всего голосов 7: ↑7 и ↓0+7
Комментарии19

Публикации

Истории

Работа

Программист C++
112 вакансий
QT разработчик
4 вакансии

Ближайшие события

22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
2 – 18 декабря
Yandex DataLens Festival 2024
МоскваОнлайн
11 – 13 декабря
Международная конференция по AI/ML «AI Journey»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань