Обработка ошибок и std::optional

Добавлено 29 октября 2022 в 21:57

В предыдущей статье было описано, как использовать std::optional. Этот тип-обертка (также называемый «словарным типом») удобен, когда вы хотите выразить, что что-то «обнуляемо» и может быть «пустым». Например, вы можете вернуть std::nullopt, чтобы указать, что код сгенерировал ошибку… но лучший ли это выбор?

Обработка ошибок и std::optional

Содержание

В чем проблема

Давайте посмотрим на пример:

struct SelectionData
{
    bool anyCivilUnits { false };
    bool anyCombatUnits { false };
    int numAnimating { 0 };
};

std::optional<SelectionData> 
CheckSelection(const ObjSelection &objList)
{   
    if (!objList.IsValid())
        return { };

    SelectionData out;   

    // сканирование...

    return {out};
}

Основная идея заключается в том, что если выделение корректно, вы можете выполнить сканирование и найти «гражданские юниты», «боевые юниты» или ряд анимируемых объектов. После завершения сканирования мы можем создать объект SelectionData и обернуть его std::optional. Если выделение не готово, то возвращаем nullopt – пустой optional.

Хотя код выглядит красиво, вы можете задать вопрос: а как насчет обработки ошибок?

Проблема с std::optional в том, что мы теряем информацию об ошибках. Функция возвращает значение или что-то пустое, поэтому вы не можете сказать, что пошло не так. В случае с этой функцией у нас был только один способ выйти раньше – если выделение некорректно. Но в более сложном примере причин может быть несколько.

Как вы думаете? Является ли это использование std::optional корректным?

Попробуем найти ответ.

Обработка ошибок

Как вы, возможно, уже знаете, существует множество способов обработки ошибок. И что еще сложнее, так это то, что у нас бывают разные виды ошибок.

В C++ мы можем сделать две вещи:

  • использовать код ошибки/специальное значение;
  • выбросить исключение.

Конечно с некоторыми вариациями:

  • вернуть некоторый код ошибки и вернуть вычисленное значение в качестве выходного параметра;
  • вернуть уникальное значение для вычисленного результата, чтобы указать на ошибку (например, -1, npos);
  • генерировать исключение – поскольку исключения считаются «тяжелыми» и добавляют некоторые накладные расходы, многие проекты используют их экономно
    • плюс мы должны принять решение, что выбросить;
  • возвращать пару <значение, код_ошибки>;
  • возвращать variant/размеченный union<значение, ошибка>;
  • установить какой-то специальный глобальный объект ошибки (например, как errno для fopen) – часто в стиле C API;
  • что-то другое… ?

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

У нас может быть несколько типов разочарований:

  • системное/ОС;
  • серьезное;
  • важное;
  • обычное;
  • незначительное;
  • ожидаемоее/вероятное.

Кроме того, мы можем посмотреть на обработку ошибок с точки зрения производительности. Мы хотели бы, чтобы она была быстрой, и использование некоторых дополнительных механизмов для обработки ошибок может быть невозможным (например, в мире встраиваемых систем). Так, например, исключения считаются «тяжелыми» и обычно не используются в низкоуровневом коде.

Где подходит std::optional?

Я думаю, что с std::optional мы просто получили еще один инструмент, который может улучшить код.

Вариант с std::optional

Как я уже несколько раз отмечал, std::optional следует использовать в основном в контексте типов, допускающих нулевое значение.

Из документации boost::optional: Когда использовать optional

optional<T> рекомендуется использовать в ситуациях, когда есть ровно одна, ясная (для всех сторон) причина отсутствия значения типа T, и где отсутствие значения так же естественно, как и наличие любого обычного значения типа Т.

Я также могу утверждать, что, поскольку optional добавляет к нашему типу значение "null", это близко к использованию указателей и nullptr. Например, я видел много кода, в котором возвращался корректный указатель в случае успеха и nullptr в случае ошибки.

TreeNode* FindNode(TheTree* pTree, string_view key)
{   
    // поиск...
    if (found)
        return pNode;

    return nullptr;
}

Или если мы перейдем к некоторым функциям уровня C:

FILE * pFile = nullptr;
pFile = fopen ("temp.txt","w");
if (pFile != NULL)
{
    fputs ("fopen example",pFile);
    fclose (pFile);
}

И даже в C++ STL мы возвращаем npos в случае неудачного поиска в строке. Только вместо nullptr этот поиск использует специальное значение для обозначения ошибки (может быть, не сбоя, а вероятной ситуации, когда мы что-то не смогли найти).

std::string s = "test";
if(s.find('a') == std::string::npos)
    std::cout << "no 'a' in 'test'\n";

Я думаю, что в приведенном выше примере (с npos) мы могли бы смело переписать его на optional. И каждый раз, когда у вас есть функция, которая что-то вычисляет, и результат может быть пустым, тогда std::optional – это то, что вам нужно.

Когда другой разработчик видит объявление типа:

std::optional<Object> PrepareData(inputs...);

Ему будет понятно, что Object иногда может не вычисляться, и это намного лучше, чем

// возвращает nullptr в случае неудачи! проверьте результат на это!
Object* PrepareData(inputs...);

Хотя версия с optional может выглядеть лучше, обработка ошибок всё еще здесь довольно «слабая».

Как насчет других способов?

В качестве альтернативы, если вы хотите передать больше информации о «разочарованиях», вы можете подумать об std::variant<Result, Error_Code> или о новом предложении для стандарта Expected<T, E>, которые заключают ожидаемое значение с кодом ошибки. На вызывающей стороне можно определить причину сбоя:

// воображаемый пример для std::expected
std::expected<Object, error_code> PrepareData(inputs...);

// вызов:
auto data = PrepareData(...);
if (data) 
    use(*data);
else 
    showError(data.error());

Когда у вас есть std::optional, вы должны проверить, есть ли значение или нет. Мне нравятся идеи функционального стиля от Саймона Брэнда, где вы можете изменить, например, такой код:

std::optional<image_view> get_cute_cat (image_view img) {
    auto cropped = find_cat(img);
    if (!cropped) {
      return std::nullopt;
    }

    auto with_sparkles = make_eyes_sparkle(*with_tie);
    if (!with_sparkles) {
      return std::nullopt;
    }

    return add_rainbow(make_smaller(*with_sparkles));
}

на следующий:

std::optional<image_view> get_cute_cat (image_view img) {
    return find_cat(img)
           .and_then(make_eyes_sparkle)
           .map(make_smaller)
           .map(add_rainbow);
}

Подробнее в его посте: Functional exceptionless error-handling with optional and expected.

Последовательность и простота

Я считаю, что, хотя у нас есть много вариантов для обработки ошибок, ключевым моментом здесь является «последовательность».

Если у вас есть один проект, который использует 10 способов обработки ошибок, может быть сложно писать новые его части, так как программисты будут путаться, что использовать.

Вероятно, невозможно придерживаться одного варианта: в некоторых критических по производительности фрагментах кода исключения не подходят, и даже типы-обертки (такие как optional, variant, expected) добавляют некоторые накладные расходы. Идеальный путь – сохранение минимума подходящих инструментов.

Еще одна мысль по этому поводу – насколько ваш код понятен и прост. Потому что если у вас есть относительно короткие функции, которые делают только одну вещь, то представлять разочарования легко – так как вариантов всего несколько. Но если ваш метод длинный, с несколькими обязанностями, вы можете получить совершенно новую сложность ошибок.

Сохранение простоты кода поможет вызывающему обрабатывать результат более явно.

Заключение

В данной статье я рассмотрел некоторые варианты обработки ошибок (или разочарований) в нашем коде на C++.

Где подходит std::optional?

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

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

Что вы думаете об использовании std::optional для обработки ошибок? Вы используете его таким образом в своем коде?

Теги

C++ / CppC++17std::optionalSTL / Standard Template Library / Стандартная библиотека шаблоновПрограммирование

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.