Посмотрев на новый тип из грядущего стандарта под названием 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

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


  1. arteast
    25.05.2023 07:36
    +1

    Чего-то похожего можно добиться, используя просто std::expected<T,std::error_condition>. Все pro-пункты применимы, как и два из трех contra (или boost::system::error_code, который уже содержит в себе возможность прицепить как раз source_location). Оно менее универсально, чем ваш вариант - два уровня иерархии вместо произвольного, и нельзя добавить доп. поля в какой-то тип ошибки - но зато готовое решение.

    Еще одна интересная особенность таких type erased решений - проблемы с работой между несколькими DSO (то есть при передаче expected из вызываемой функции, находящейся в одной DSO, в вызывающую функцию из другого DSO). Нужно убедиться, что typeid() и ваш кастомный GetTypeId() выдают идентичный результат в обоих DSO для любого типа, который используется в ошибке (т.е. все типы ошибок для вашего варианта и все типы категорий для std::error_condition/error_code), а это может быть нетривиальненько. Я в это наступил, когда использовал asio в одном DLL, а проверял результат (boost::error_code, который использует typeid() для сравнений категорий) на равенство конкретной ошибке в другом DLL.


  1. eao197
    25.05.2023 07:36
    +1

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

    Думаю, что вот этот тезис:

    а также освобождает программиста от рутинных задач, таких как явное указание noexcept в API своего проекта

    нуждается в раскрытии и объяснении. Если у вас в проекте исключения разрешены, то каким образом std::expected позволяет не писать noexcept? Какое отношение std::expected в проекте с исключениями вообще имеет к тому, может ли вылететь из функции/метода исключение или нет?


    1. broly Автор
      25.05.2023 07:36

      Я подразумевал именно отключенные исключения в проекте. Собственно и написал эту статью, чтобы рассказать о приближении сего инструмента к исключениям: новомодный стандартный expected, но с привычным похожим интерфейсом :)


      1. eao197
        25.05.2023 07:36

        Так если исключения отключены, то зачем расставлять noexcept? У вас же тогда, по сути, все функции/методы noexcept.


        1. apro
          25.05.2023 07:36
          +1

          Стандартная библиотека осуществляет ряд оптимизаций на основе того объявлена функция noexcept или нет. И здесь утверждается что флаг -fno-except и noexcept для gcc и clang это разные вещи: https://stackoverflow.com/questions/10787766/when-should-i-really-use-noexcept#comment121480642_67134262


  1. LeetcodeM0nkey
    25.05.2023 07:36
    +2

    Всё же исключение это в первую очередь радикальное изменение хода выполнения программы. Поэтому если для вас исключение это "разворачивание стека и проблема производительности", и, скорее всего, в обработчике вы просто проверяете значение ошибки с минимумом изменений в control flow, то, скорее всего, вы что-то спроектировали не так.
    std::expected это скорее ещё одно решение когда пространство значений возвращаемого результата не может включать специальных значений для сигнализации о чём-то плохом. Но это никак не альтернатива исключениям в принципе.


    1. broly Автор
      25.05.2023 07:36

      Конечно не альтернатива, но суть, как по мне, приближена. Кстати, в конце статьи я упомянул про монады. Если совместить монадический интерфейс expected с сопрограммами, то будет очень похоже на исключения - при любой ошибке, можно будет выходить из сопрограмм в автоматическом режиме обратно к точке вызова.


  1. isadora-6th
    25.05.2023 07:36

    std::expected вообще спорная вещь и я искренне не понимаю всего восторга вокруг этой штуки. Ну и меня смущают утверждения типа expected быстрее по перформансу чем exception.

    Это, при том, что раскрутка стэка и весь оверхед эксепшенов происходит только в момент этого самого exception (кроме оверхеда на поддержку эксепшенов, он вообще в компайл-тайм и даже если вы их не применяете -- он есть). Я не смог придумать хороший бенчмарк, что-бы это доказать, вообще не понятно как это правильно мерять.

    Я на std::expected смотрю как на симпатичную замену std::pair<T*, E*> в возвращаемых значениях функции т.к. optional несет недостаточно информации о причинах ошибки.

    Будет крайне жаль, если индустрия поведется на этот инструмент и за-моветонит exception из-за чего мы получим, что почти каждая функция будет возвращать std::expected т.к. проблему на своём уровне абстракции решить невозможно, эффективно получая exception функционал, только сложным путем.

    Есть вопросы к лишним зависимостям на новый GOD-file errors.hpp, от которого теперь зависит весь проект.

    exception-style объявления и зависимости мне нравятся все таки больше именованных интов.

    struct MyCoolException : public std::runtime_error {

    public:

    using std::runtime_error::runtime_error;

    }


    1. LeetcodeM0nkey
      25.05.2023 07:36

      Это, при том, что раскрутка стэка и весь оверхед эксепшенов происходит только в момент этого самого exception

      А сам exception, если действительно применять их по назначению, это чуть менее 0,01% всего остального времени исполнения.


    1. apro
      25.05.2023 07:36
      +2

      Это, при том, что раскрутка стэка и весь оверхед эксепшенов происходит только в момент этого самого exception

      Это в теории, на практике ряд оптимизаций становятся невозможными из-за исключений. И правильно расставленные noexcept уменьшают количество генерируемого кода, см. например доклад “There Are No Zero-cost Abstractions”, там в частности рассматривается ассебмлер для "сырого" указателя и unique_ptr и демострирует как noexcept помогает бороться с генерацией лишних инструкций: https://youtu.be/rHIkrotSwcc?t=1252