
Пару недель назад прошла главная конференция в С++ мире — 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 <- readLine(); auto bStr <- readLine(); auto a <- parseInt(aStr); auto b <- parseInt(bStr); return safeAdd(a, b) }
Некотороые авторы предлагают такой синтаксис:
try { auto aStr = try readLine(); auto bStr = try readLine(); auto a = try parseInt(aStr); auto b = try parseInt(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 скорее всего появятся все возможные способы.
