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