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

от автора

image

Пару недель назад прошла главная конференция в С++ мире — 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; }

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

  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; }

Можно убрать 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 скорее всего появятся все возможные способы.

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

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


ссылка на оригинал статьи https://habr.com/post/426965/


Комментарии

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

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