Пару недель назад прошла главная конференция в С++ мире — CPPCON.
Пять дней подряд с 8 утра и до 10 вечера шли доклады. Программисты всех конфессий обсуждали будущее С++, травили байки и думали как сделать С++ проще.
Удивительно много докладов были посвящены обработке ошибок. Устоявшиеся подходы не позволяют достичь максимальной производительности или могут порождать простыни кода.
Какие же нововведения ожидают нас в С++2a?
Немного теории
Условно все ошибочные ситуации в программе можно разделить на 2 большие группы:
- Фатальные ошибки.
- Не фатальные, или ожидаемые ошибки.
Фатальные ошибки
После них не имеет смысла продолжать выполнение.
Например это разыменование нулевого указателя, проезд по памяти, деление на 0 или нарушение других инвариантов в коде. Всё что нужно сделать при их возникновении — это сообщить максимум информации о проблеме и завершить программу.
В C++ слишком много уже достаточно способов что бы завершить программу:
Даже начинают появляться библиотеки для сбора данных о крешах (1, 2, 3).
Не фатальные ошибки
Это ошибки появления которых предусмотрены логикой работы программы. Например, ошибки при работе с сетью, конвертация невалидной строки в число и т.д. Появление таких ошибок в программе в порядке вещей. Для их обработки существует несколько общепринятых в С++ тактик.
О них мы и поговорим более подробно на простом примере:
Попробуем написать функцию void addTwo()
с использованием разных подходов к обработке ошибок.
Функция должна считать 2 строки, преобразовать их в int
и распечатать сумму. Нужно обработать ошибки IO, переполнение и конвертацию в число. Я буду опускать неинтересные детали реализации. Мы рассмотрим 3 основных подхода.
1. Исключения
// Считывает строку из консоли
// При ошибках IO выбрасывает std::runtime_error
std::string readLine();
// Преобразовывает строку в int
// В случае ошибки выбрасывает std::invalid_argument
int parseInt(const std::string& str);
// Складывает a и b
// в случае переполнения выбрасывает std::overflow_error
int safeAdd(int a, int b);
void addTwo() {
try {
std::string aStr = readLine();
std::string bStr = readLine();
int a = parseInt(aStr);
int b = parseInt(bStr);
std::cout << safeAdd(a, b) << std::endl;
} catch(const std::exeption& e) {
std::cout << e.what() << std::endl;
}
}
Исключения в С++ позволяют обрабатывать ошибки централизованно без лишней лапши в коде
,
но за это приходится расплачиваться целым ворохом проблем.
- накладные расходы при обработке исключений довольно большие, нельзя часто выбрасывать исключения.
- лучше не выбрасывать исключения из конструкторов/деструкторов и соблюдать RAII.
- по сигнатуре функции невозможно понять какое исключение может вылететь из функции.
- размер бинарного файла увеличивается за счёт дополнительного кода поддержки исключений.
2. Коды возврата
Классический подход унаследованный о C.
bool readLine(std::string& str);
bool parseInt(const std::string& str, int& result);
bool safeAdd(int a, int b, int& result);
void processError();
void addTwo() {
std::string aStr;
int ok = readLine(aStr);
if (!ok) {
processError();
return;
}
std::string bStr;
ok = readLine(bStr);
if (!ok) {
processError();
return;
}
int a = 0;
ok = parseInt(aStr, a);
if (!ok) {
processError();
return;
}
int b = 0;
ok = parseInt(bStr, b);
if (!ok) {
processError();
return;
}
int result = 0;
ok = safeAdd(a, b, result);
if (!ok) {
processError();
return;
}
std::cout << result << std::endl;
}
Выглядит не очень?
- Нельзя вернуть настоящее значение функции.
- Очень просто забыть обработать ошибку (когда вы последний раз вы проверяли код возврата у printf?).
- Приходится писать код обработки ошибок рядом с каждой функцией. Такой код сложнее читать.
С помощью С++17 и C++2a последовательно починим все эти проблемы.
3. C++17 и nodiscard
В C++17 появился атрибут nodiscard
.
Если указать его перед объявлением функции, то отсутствие проверки возвращаемого значения вызовет предупреждение компилятора.
[[nodiscard]] bool doStuff();
/* ... */
doStuff(); // Предупреждение компилятора!
bool ok = doStuff(); // Ок.
Так же nodiscard
можно указать для класса, структуры или enum class.
В таком случае действие атрибута распространится на все функции возвращающие значения типа помеченного nodiscard
.
enum class [[nodiscard]] ErrorCode {
Exists,
PermissionDenied
};
ErrorCode createDir();
/* ... */
createDir();
Я не буду приводить код с nodiscard
.
C++17 std::optional
В C++ 17 появился std::optional<T>
.
Посмотрим как код выглядит сейчас.
std::optional<std::string> readLine();
std::optional<int> parseInt(const std::string& str);
std::optional<int> safeAdd(int a, int b);
void addTwo() {
std::optional<std::string> aStr = readLine();
std::optional<std::string> bStr = readLine();
if (aStr == std::nullopt || bStr == std::nullopt){
std::cerr << "Some input error" << std::endl;
return;
}
std::optional<int> a = parseInt(*aStr);
std::optional<int> b = parseInt(*bStr);
if (!a || !b) {
std::cerr << "Some parse error" << std::endl;
return;
}
std::optional<int> result = safeAdd(a, b);
if (!result) {
std::cerr << "Integer overflow" << std::endl;
return;
}
std::cout << *result << std::endl;
}
Можно убрать in-out аргументы у функций и код станет чище.
Однако, мы теряем информацию о ошибке. Стало непонятно когда и что пошло не так.
Можно заменить std::optional
на std::variant<ResultType, ValueType>
.
Код получится по смыслу такой же как с std::optional
, но более громоздкой.
C++2a и std::expected
std::expected<ResultType, ErrorType>
— специальный шаблонный тип, он возможно попадёт в ближайший незавершённый стандарт.
У него 2 параметра.
ReusltType
— ожидаемое значение.ErrorType
— тип ошибки.
std::expected
может содержать либо ожидаемое значение, либо ошибку. Работа с этим типом это будет примерно такой:std::expected<int, string> ok = 0; expected<int, string> notOk = std::make_unexpected("something wrong");
Чем же это отличается от обычного variant
? Что делает его особенным?
std::expected
будет монадой.
Предлагается поддержать пачку операций над std::expected
как над монадой: map
, catch_error
, bind
, unwrap
, return
и then
.
С использованием этих функций можно будет связывать вызовы функций в цепочку.
getInt().map([](int i)return i * 2;)
.map(integer_divide_by_2)
.catch_error([](auto e) return 0; );
Пусть у нас есть функции написанный с использованием std::expected
.
std::expected<std::string, std::runtime_error> readLine();
std::expected<int, std::runtime_error> parseInt(const std::string& str);
std::expected<int, std::runtime_error> safeAdd(int a, int b);
Ниже только псевдокод, его нельзя заставить работать ни в одном современном компиляторе.
Можно попробовать позаимствовать из Haskell do-синтаксис записи операций над монадами. Почему бы не разрешить делать так:
std::expected<int, std::runtime_error> result = do {
auto aStr <- readInt();
auto bStr <- readInt();
auto a <- readInt(aStr);
auto b <- readInt(bStr);
return safeAdd(a, b)
}
Некотороые авторы предлагают такой синтаксис:
try {
auto aStr = try readInt();
auto bStr = try readInt();
auto a = try readInt(aStr);
auto b = try readInt(bStr);
std::cout result << std::endl;
return safeAdd(a, b)
} catch (const std::runtime_error& err) {
std::cerr << err.what() << std::endl;
return 0;
}
Компилятор автоматически преобразует такой блок кода в последовательность вызова функций. Если в какой-то момент функция вернёт не то что от нее ожидают, цепочка вычислений прервётся. Да и в качестве типа ошибки можно использовать уже существующие в стандарте типы исключений: std::runtime_error
, std::out_of_range
и т.д.
Если получится хорошо запроектировать синтаксис, то std::expected
позволит писать простой и эффективный код.
Заключение
Идеального способа для обработки ошибок не существует. До недавнего времени в С++ были почти все возможные способы обработки ошибок кроме монад.
В С++2a скорее всего появятся все возможные способы.
Что почитать и посмотреть по теме
- Акттуальный proposal.
- Выступление про std::expected c CPPCON.
- Андрей Александреску про std::expected на C++ Russia.
- Более-менее свежее обсуждение proposal на Reddit.
Автор: shaggyboo