Хабрахабр

Что будет с обработкой ошибок в С++2a

image

Программисты всех конфессий обсуждали будущее С++, травили байки и думали как сделать С++ проще. Пару недель назад прошла главная конференция в С++ мире — CPPCON.
Пять дней подряд с 8 утра и до 10 вечера шли доклады.

Устоявшиеся подходы не позволяют достичь максимальной производительности или могут порождать простыни кода.
Какие же нововведения ожидают нас в С++2a? Удивительно много докладов были посвящены обработке ошибок.

Немного теории

Условно все ошибочные ситуации в программе можно разделить на 2 большие группы:

  • Фатальные ошибки.
  • Не фатальные, или ожидаемые ошибки.

Фатальные ошибки

Всё что нужно сделать при их возникновении — это сообщить максимум информации о проблеме и завершить программу. После них не имеет смысла продолжать выполнение.
Например это разыменование нулевого указателя, проезд по памяти, деление на 0 или нарушение других инвариантов в коде.

В C++ слишком много уже достаточно способов что бы завершить программу:

Даже начинают появляться библиотеки для сбора данных о крешах (1, 2, 3).

Не фатальные ошибки

Например, ошибки при работе с сетью, конвертация невалидной строки в число и т.д. Это ошибки появления которых предусмотрены логикой работы программы. Для их обработки существует несколько общепринятых в С++ тактик.
О них мы и поговорим более подробно на простом примере: Появление таких ошибок в программе в порядке вещей.

Нужно обработать ошибки IO, переполнение и конвертацию в число. Попробуем написать функцию void addTwo() с использованием разных подходов к обработке ошибок.
Функция должна считать 2 строки, преобразовать их в int и распечатать сумму. Мы рассмотрим 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() 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;
}

Выглядит не очень?

  1. Нельзя вернуть настоящее значение функции.
  2. Очень просто забыть обработать ошибку (когда вы последний раз вы проверяли код возврата у printf?).
  3. Приходится писать код обработки ошибок рядом с каждой функцией. Такой код сложнее читать.
    С помощью С++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;
}

Стало непонятно когда и что пошло не так.
Можно заменить std::optional на std::variant<ResultType, ValueType>.
Код получится по смыслу такой же как с std::optional, но более громоздкой. Можно убрать in-out аргументы у функций и код станет чище.
Однако, мы теряем информацию о ошибке.

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");

Что делает его особенным?
std::expected будет монадой.
Предлагается поддержать пачку операций над std::expected как над монадой: map, catch_error, bind, unwrap, return и then.
С использованием этих функций можно будет связывать вызовы функций в цепочку. Чем же это отличается от обычного variant?

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 <- parseInt(aStr); auto b <- parseInt(bStr); return safeAdd(a, b)
}

Некотороые авторы предлагают такой синтаксис:

try { auto aStr = try readInt(); auto bStr = try readInt(); 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 скорее всего появятся все возможные способы. Идеального способа для обработки ошибок не существует.

Что почитать и посмотреть по теме

  1. Акттуальный proposal.
  2. Выступление про std::expected c CPPCON.
  3. Андрей Александреску про std::expected на C++ Russia.
  4. Более-менее свежее обсуждение proposal на Reddit.
Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть