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

в 22:43, , рубрики: C, c++, c++17, c++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.

Автор: shaggyboo

Источник

* - обязательные к заполнению поля