- PVSM.RU - https://www.pvsm.ru -

Детерминированные исключения и обработка ошибок в «C++ будущего»

Странно, что на Хабре до сих пор не было упомянуто о наделавшем шуму предложении к стандарту C++ под названием "Zero-overhead deterministic exceptions". Исправляю это досадное упущение.

Если вас беспокоит оверхед исключений, или вам приходилось компилировать код без поддержки исключений, или просто интересно, что будет с исключениями в C++2b (отсылка к недавнему посту [1]), прошу под кат. Вас ждёт выжимка из всего, что сейчас можно найти по теме, и пара опросов.

Разговор далее будет вестись не только про статические исключения, но и про связанные предложения к стандарту, и про всякие другие способы обработки ошибок. Если вы зашли сюда поглядеть на синтаксис, то вот он:

double safe_divide(int x, int y) throws(arithmetic_error) {
    if (y == 0) {
        throw arithmetic_error::divide_by_zero;
    } else {
        return as_double(x) / y;
    }
}

void caller() noexcept {
    try {
        cout << safe_divide(5, 2);
    } catch (arithmetic_error e) {
        cout << e;
    }
}

Если конкретный тип ошибки неважен/неизвестен, то можно использовать просто throws и catch (std::error e).

Полезно знать

std::optional и std::expected

Пусть мы решили, что ошибка, которая потенциально может возникнуть в функции, недостаточно «фатальная», чтобы бросать из неё исключение. Традиционно информацию об ошибке возвращают с помощью выходного параметра (out parameter). Например, Filesystem TS [2] предлагает ряд подобных функций:

uintmax_t file_size(const path& p, error_code& ec);

(Не бросать же исключение из-за того, что файл не найден?) Тем не менее, обработка кодов ошибок громоздкая и подвержена багам. Код ошибки легко забыть проверить. Современные стили кода запрещают [3] использование выходных параметров, вместо них рекомендуется возвращать структуру, содержащую весь результат.

Boost вот уже некоторое время предлагает изящное решение для обработки таких «не-фатальных» ошибок, которые в определённых сценариях могут происходить сотнями в корректной программе:

expected<uintmax_t, error_code> file_size(const path& p);

Тип expected похож на variant, но предоставляет удобный интерфейс для работы с "результатом" и "ошибкой". По умолчанию, в expected хранится "результат". Реализация file_size может выглядеть как-то так:

file_info* info = read_file_info(p);
if (info != null) {
    uintmax_t size = info->size;
    return size;  // <==
} else {
    error_code error = get_error();
    return std::unexpected(error);  // <==
}

Если причина ошибки нам неинтересна, или ошибка может заключаться только в "отсутствии" результата, то можно использовать optional:

optional<int> parse_int(const std::string& s);
optional<U> get_or_null(map<T, U> m, const T& key);

В C++17 из Boost в std попал optional [4] (без поддержки optional<T&>), в C++20 добавили expected [5].

Contracts

Контракты [6] (не путать с концептами) — новый способ наложить ограничения на параметры функции, добавленный в C++20. Добавлены 3 аннотации:

  • expects проверяет параметры функции
  • ensures проверяет возвращаемое значение функции (принимает его в качестве аргумента)
  • assert — цивилизованная замена макросу assert

double unsafe_at(vector<T> v, size_t i) [[expects: i < v.size()]];
double sqrt(double x) [[expects: x >= 0]] [[ensures ret: ret >= 0]];

value fetch_single(key e) {
    vector<value> result = fetch(vector<key>{e});
    [[assert result.size() == 1]];
    return v[0];
}

Можно настроить, чтобы нарушение контрактов:

  • Вызывало Undefined Behaviour, или
  • Проверялось и вызывало пользовательский обработчик, после чего std::terminate

Продолжать работу программы после нарушения контракта никак нельзя, потому что компиляторы используют гарантии из контрактов для оптимизации кода функции. Если есть малейшее сомнение в том, что контракт выполнится, стоит добавить дополнительную проверку.

std::error_code

Библиотека <system_error>, добавленная в C++11, позволяет унифицировать обработку кодов ошибок в вашей программе. std::error_code [7] состоит из кода ошибки типа int и указателя на объект какого-нибудь класса-наследника std::error_category [8]. Этот объект, по сути, играет роль таблицы виртуальных функций и определяет поведение данного std::error_code.

Чтобы создавать свои std::error_code, вы должны определить свой класс-наследник std::error_category и реализовать виртуальные методы, самым важным из которых является:

virtual std::string message(int c) const = 0;

Нужно также создать глобальную переменную вашего std::error_category. Обработка ошибок при помощи error_code + expected выглядит как-то так:

template <typename T>
using result = expected<T, std::error_code>;

my::file_handle open_internal(const std::string& name, int& error);

auto open_file(const std::string& name) -> result<my::file>
{
    int raw_error = 0;
    my::file_handle maybe_result = open_internal(name, &raw_error);
    std::error_code error{raw_error, my::filesystem_error};
    if (error) {
        return unexpected{error};
    } else {
        return my::file{maybe_result};
    }
}

Важно, что в std::error_code значение 0 означает отсутствие ошибки. Если для ваших кодов ошибок это не так, то перед тем, как конвертировать системный код ошибки в std::error_code, надо заменить код 0 на код SUCCESS, и наоборот.

Все системные коды ошибок описаны в errc [9] и system_category [10]. Если на определённом этапе ручной проброс кодов ошибки становится слишком муторным, то всегда можно завернуть код ошибки в исключение std::system_error и выбросить.

Destructive move / Trivially relocatable

Пусть вам нужно создать очередной класс объектов, владеющих какими-нибудь ресурсами. Скорее всего, вы захотите сделать его некопируемым, но перемещаемым (moveable), потому что с unmoveable объектами неудобно работать (до C++17 их нельзя было вернуть из функции).

Но вот беда: перемещённый объект в любом случае нужно удалить. Поэтому необходимо особое состояние "moved-from", то есть "пустого" объекта, который ничего не удаляет. Получается, каждый класс C++ обязан иметь пустое состояние, то есть невозможно создать класс с инвариантом (гарантией) корректности, от конструктора до деструктора. Например, невозможно создать корректный класс open_file файла, который открыт на всём протяжении времени жизни. Странно наблюдать это в одном из немногих языков, активно использующих RAII.

Другая проблема — зануление старых объектов при перемещении добавляет оверхед: заполнение std::vector<std::unique_ptr<T>> может быть до 2 раз медленнее, чем std::vector<T*> из-за кучи занулений старых указателей при перемещении, с последующим удалением пустышек.

Разработчики C++ давно облизываются на Rust, где у перемещённых объектов не вызываются деструкторы. Эта фича называется Destructive move. К сожалению, Proposal Trivially relocatable [11] не предлагает добавить её в C++. Но проблему оверхеда решит.

Класс считается Trivially relocatable, если две операции: перемещения и удаления старого объекта — эквивалентны memcpy из старого объекта в новый. Старый объект при этом не удаляется, авторы называют это "drop it on the floor".

Тип является Trivially relocatable с точки зрения компилятора, если выполняется одно из следующих (рекурсивных) условий:

  1. Он trivially moveable + trivially destructible (например, int или POD структура)
  2. Это класс, помеченный атрибутом [[trivially_relocatable]]
  3. Это класс, все члены которого являются Trivially relocatable

Использовать эту информацию можно с помощью std::uninitialized_relocate, которая исполняет move init + delete обычным способом, или ускоренным, если это возможно. Предлагается пометить как [[trivially_relocatable]] большинство типов стандартной библиотеки, включая std::string, std::vector, std::unique_ptr. Оверхед std::vector<std::unique_ptr<T>> с учётом этого Proposal исчезнет.

Что не так с исключениями сейчас?

Механизм исключений C++ разрабатывался в 1992 году. Были предложены различные варианты реализации. Из них в итоге был выбран механизм таблиц исключений, которые гарантируют отсутствие оверхеда для основного пути выполнения программы. Потому что с самого момента их создания создания предполагалось, что исключения должны выбрасываться очень редко.

Недостатки динамических (то есть обычных) исключений:

  1. В случае выброшенного исключения оверхед составляет в среднем порядка 10000-100000 циклов CPU, а в худшем случае может достигать порядка миллисекунд
  2. Увеличение размера бинарного файла на 15-38%
  3. Несовместимость с программным интерфейсом С
  4. Неявная поддержка проброса исключений во всех функциях, кроме noexcept. Исключение может быть выброшено практически в любом месте программы, даже там, где автор функции его не ожидает

Из-за этих недостатков существенно ограничивается область применения исключений. Когда исключения не могут применяться:

  1. Там, где важен детерминизм, то есть там, где недопустимо, чтобы код "иногда" работал в 10, 100, 1000 раз медленнее, чем обычно
  2. Когда они не поддерживаются в ABI, например, в микроконтроллерах
  3. Когда значительная часть кода написана на С
  4. В компаниях с большим грузом легаси-кода (Google Style Guide [12], Qt [13]). Если в коде есть хоть одна не exception-safe функция, то по закону подлости через неё рано или поздно прокинут исключение и создадут баг
  5. В компаниях, набирающих программистов, которые понятия не имеют об exception safety

По опросам, на местах работы 52% (!) разработчиков исключения запрещены корпоративными правилами.

Но исключения — неотъемлемая часть C++! Включая флаг -fno-exceptions, разработчики теряют возможность использовать значительную часть стандартной библиотеки. Это дополнительно подстрекает компании насаждать собственные "стандартные библиотеки" и да, изобретать свой класс строки.

Но и это ещё не конец. Исключения — единственный стандартный способ отменить создание объекта в конструкторе и выдать ошибку. Когда они отключены, появляется такая мерзость, как двухфазная инициализация. Операторы тоже не могут использовать коды ошибок, поэтому они заменяются функциями вроде assign.

Proposal: исключения будущего

Новый механизм передачи исключений

Herb Sutter в P709 описал новый механизм передачи исключений. Идейно, функция возвращает std::expected, однако вместо отдельного дискриминатора типа bool, который вместе с выравниванием будет занимать до 8 байт на стеке, этот бит информации передаётся каким-то более быстрым способом, например, в Carry Flag.

Функции, которые не трогают CF (таких большинство), получат возможность использовать статические исключения бесплатно — и в случае обычного возврата, и в случае проброса исключения! Функции, которые вынуждены будут его сохранять и восстанавливать, получат минимальный оверхед, и это всё равно будет быстрее, чем std::expected и любые обычные коды ошибок.

Выглядят статические исключения следующим образом:

int safe_divide(int i, int j) throws(arithmetic_errc) {
    if (j == 0)
        throw arithmetic_errc::divide_by_zero;
    if (i == INT_MIN && j == -1)
        throw arithmetic_errc::integer_divide_overflows;
    return i / j;
}

double foo(double i, double j, double k) throws(arithmetic_errc) {
    return i + safe_divide(j, k);
}

double bar(int i, double j, double k) {
    try {
        cout << foo(i, j, k);
    } catch (erithmetic_errc e) {
        cout << e;
    }
}

В альтернативной версии предлагается обязать ставить ключевое слово try в том же выражении, что вызов throws функции: try i + safe_divide(j, k). Это сведёт число случаев использования throws функций в коде, не безопасном для исключений, практически к нулю. В любом случае, в отличие от динамических исключений, у IDE будет возможность как-то выделять выражения, бросающие исключения.

То, что выброшенное исключение не сохраняется отдельно, а кладётся прямо на место возвращаемого значения, накладывает ограничения на тип исключения. Во-первых, он должен быть Trivially relocatable. Во-вторых, его размер должен быть не очень большим (но это может быть что-то вроде std::unique_ptr), иначе все функции будут резервировать больше места на стеке.

status_code

Библиотека <system_error2>, разработанная Niall Douglas, будет содержать status_code<T> — «новый, лучший» error_code. Основные отличия от error_code:

  1. status_code — шаблонный тип, который можно использовать для хранения практически любых мыслимых кодов ошибок (вместе с указателем на status_code_category), без использования статических исключений
  2. T должен быть Trivially relocatable и копируемым (последнее, ИМХО, не должно быть обязательным). При копировании и удалении вызываются виртуальные функции из status_code_category
  3. status_code может хранить не только данные об ошибке, но и дополнительные сведения об успешно завершённой операции
  4. «Виртуальная» функция code.message() возвращает не std::string, а string_ref — довольно тяжёлый тип строки, представляющий собой виртуальный «возможно владеющий» std::string_view. Туда можно запихнуть string_view или string, или std::shared_ptr<string>, или ещё какой-нибудь сумасшедший способ владения строкой. Niall утверждает, что #include <string> сделало бы заголовок <system_error2> непозволительно «тяжёлым»

Далее, вводится errored_status_code<T> — обёртка над status_code<T> со следующим конструктором:

errored_status_code(status_code<T>&& code)
    [[expects: code.failure() == true]]
    : code_(std::move(code)) {}

error

Тип исключения по умолчанию (throws без типа), а также базовый тип исключений, к которому приводятся все остальные (вроде std::exception) — это error. Он определён примерно так:

using error = errored_status_code<intptr_t>;

То есть error — это такой «ошибочный» status_code, у которого значение (value) помещается в 1 указатель. Так как механизм status_code_category обеспечивает корректное удаление, перемещение и копирование, то теоретически в error можно сохранить любую структуру данных. На практике это будет один из следующих вариантов:

  1. Целые числа (int)
  2. std::exception_handle, то есть указатель на выброшенное динамическое исключение
  3. status_code_ptr, то есть unique_ptr на произвольный status_code<T>.

Проблема в том, что случае 3 не планируется дать возможность привести error обратно к status_code<T>. Единственное, что можно сделать — получить message() упакованного status_code<T>. Чтобы иметь возможность достать обратно завёрнутое в error значение, надо выбросить его как динамическое исключение (!), потом поймать и завернуть в error. А вообще, Niall считает, что в error должны храниться только коды ошибок и строковые сообщения, чего достаточно для любой программы.

Чтобы различать разные виды ошибок, предлагается использовать «виртуальный» оператор сравнения:

try {
    open_file(name);
} catch (std::error e) {
    if (e == filesystem_error::already_exists) {
        return;
    } else {
        throw my_exception("Unknown filesystem error, unable to continue");
    }
}

Использовать несколько catch-блоков или dynamic_cast для выбора типа исключения не получится!

Взаимодействие с динамическими исключениями

Функция может иметь одну из следующих спецификаций:

  • noexcept: не бросает никаких исключений
  • throws(E): бросает только статические исключения
  • (ничего): бросает только динамические исключения

throws подразумевает noexcept. Если динамическое исключение выбрасывается из «статической» функции, то оно заворачивается в error. Если статическое исключение выбрасывается из «динамической» функции, то оно заворачивается в исключение status_error. Пример:

void foo() throws(arithmetic_errc) {
    throw erithmetic_errc::divide_by_zero;
}

void bar() throws {
    // Код arithmetic_errc помещается в intptr_t
    // Допустимо неявное приведение к error
    foo();
}

void baz() {
    // error заворачивается в исключение status_error
    bar();
}

void qux() throws {
    // error достаётся из исключения status_error
    baz();
}

Исключения в C?!

Предложение предусматривает добавление исключений в один из будущих стандартов C, причём эти исключения будут ABI-совместимы со статическими исключениями C++. Структуру, аналогичную std::expected<T, U>, пользователь должен будет объявлять самостоятельно, хотя boilerplate можно убрать с помощью макросов. Синтаксис состоит из (для простоты будем так считать) ключевых слов fails, failure, catch.

int invert(int x) fails(float) {
    if (x != 0) return 1 / x;
    else        return failure(2.0f);
}

struct expected_int_float {
    union { int value; float error; };
    _Bool failed;
};

void caller() {
    expected_int_float result = catch(invert(5));
    if (result.failed) {
        print_error(result.error);
        return;
    }
    print_success(result.value);
}

При этом в C++ тоже можно будет вызывать fails функции из C, объявляя их в блоках extern C. Таким образом, в C++ будет целая плеяда ключевых слов по работе с исключениями:

  • throw() — удалено в C++20
  • noexcept — спецификатор функции, функция не бросает динамические исключения
  • noexcept(expression) — спецификатор функции, функция не бросает динамические исключения при условии
  • noexcept(expression) — бросает ли выражение динамические исключения?
  • throws(E) — спецификатор функции, функция бросает статические исключения
  • throws = throws(std::error)
  • fails(E) — функция, импортированная из C, бросает статические исключения

Итак, в C++ завезли (точнее, завезут) тележку новых инструментов для обработки ошибок. Далее возникает логичный вопрос:

Когда что использовать?

Направление в целом

Ошибки разделяются на несколько уровней:

  • Ошибки программиста. Обрабатываются с помощью контрактов. Приводят к сбору логов и завершению работы программы в соответствие с концепцией fail-fast [14]. Примеры: нулевой указатель (когда это недопустимо); деление на ноль; ошибки выделения памяти, не предусмотренные программистом.
  • Непоправимые ошибки, предусмотренные программистом. Выбрасываются в миллион раз реже, чем обычный возврат из функции, что делает использование для них динамических исключений оправданным. Обычно в таких случаях требуется перезапустить целую подсистему программы или выдать ошибку при выполнении операции. Примеры: внезапно потеряна связь с базой данных; ошибки выделения памяти, предусмотренные программистом.
  • Поправимые (recoverable) ошибки, когда что-то помешало функции выполнить свою задачу, но вызывающая функция, возможно, знает, что с этим делать. Обрабатываются с помощью статических исключений. Примеры: работа с файловой системой; другие ошибки ввода-вывода (IO); некорректные пользовательские данные; vector::at().
  • Функция успешно завершила свою задачу, пусть и с неожиданным результатом. Возвращаются std::optional, std::expected, std::variant. Примеры: stoi() [15]; vector::find(); map::insert [16].

В стандартной библиотеке надёжнее всего будет полностью отказаться от использования динамических исключений, чтобы сделать компиляцию «без исключений» легальной.

errno

Функции, использующие errno для быстрой и минималистичной работы с кодами ошибок C и C++, должны быть заменены на fails(int) и throws(std::errc), соответственно. Некоторое время старый и новый варианты функций стандартной библиотеки будут сосуществовать, потом старые объявят deprecated.

Out of memory

Ошибки выделения памяти обрабатывает глобальный хук new_handler, который может:

  1. Устранить нехватку памяти и продолжить выполнение
  2. Выбросить исключение
  3. Аварийно завершить программу

Сейчас по умолчанию выбрасывается std::bad_alloc. Предлагается же по умолчанию вызывать std::terminate(). Если вам нужно старое поведение, замените обработчик на тот, который вам нужен, в начале main().

Все существующие функции стандартной библиотеки станут noexcept и будут крашить программу при std::bad_alloc. В то же время, будут добавлены новые API вроде vector::try_push_back, которые допускают ошибки выделения памяти.

logic_error

Исключения std::logic_error, std::domain_error, std::invalid_argument, std::length_error, std::out_of_range, std::future_error сообщают о нарушении предусловия функции. В новой модели ошибок вместо них должны использоваться контракты. Перечисленные типы исключений не будут объявлены deprecated, но почти все случаи их использования в стандартной библиотеке будут заменены на [[expects: …]].

Текущее состояние Proposal

Proposal сейчас находится в состоянии черновика. Он уже довольно сильно поменялся, и ещё может сильно измениться. Некоторые наработки не успели опубликовать, так что предлагаемый API <system_error2> не совсем актуален.

Предложение описывается в 3 документах:

  1. P709 [17] — первоначальный документ от Herb Sutter
  2. P1095 [18] — детерминированные исключения в видении Niall Douglas, некоторые моменты изменены, добавлена совместимость с языком C
  3. P1028 [19] — API из тестовой реализации [20] std::error

На настоящий момент не существует компилятора, который поддерживает статические исключения. Соответственно, сделать их бенчмарки пока невозможно.

При наилучшем раскладе детерминированные исключения будут готовы и попадут в C++23. Если не успеют, то, вероятно, попадут в C++26, так как комитет стандартизации, в целом, заинтересован темой.

Заключение

Многие детали предлагаемого подхода к обработке исключений я опустил или умышленно упростил, зато прошёлся по большинству тем, требующихся для понимания статических исключений. Если возникли дополнительные вопросы, то задайте их в комментариях или обратитесь к документам по ссылкам выше. Любые поправки приветствуются.

И конечно, обещанные опросы ^^

Автор: Anton3

Источник [21]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/programmirovanie/300076

Ссылки в тексте:

[1] недавнему посту: https://habr.com/post/426965/

[2] Filesystem TS: https://en.cppreference.com/w/cpp/experimental/fs

[3] запрещают: https://github.com/isocpp/CppCoreGuidelines/blob/master/CppCoreGuidelines.md#f21-to-return-multiple-out-values-prefer-returning-a-struct-or-tuple

[4] optional: https://en.cppreference.com/w/cpp/utility/optional

[5] expected: https://github.com/TartanLlama/expected

[6] Контракты: https://en.cppreference.com/w/cpp/language/attributes/contract

[7] std::error_code: https://en.cppreference.com/w/cpp/error/error_code

[8] std::error_category: https://en.cppreference.com/w/cpp/error/error_category

[9] errc: https://en.cppreference.com/w/cpp/error/errc

[10] system_category: https://en.cppreference.com/w/cpp/error/system_category

[11] Trivially relocatable: https://wg21.link/P1144

[12] Google Style Guide: https://google.github.io/styleguide/cppguide.html#Exceptions

[13] Qt: https://doc.qt.io/qt-5/exceptionsafety.html

[14] fail-fast: https://en.wikipedia.org/wiki/Fail-fast

[15] stoi(): https://ru.cppreference.com/w/cpp/string/basic_string/stol

[16] map::insert: https://en.cppreference.com/w/cpp/container/map/insert

[17] P709: https://wg21.link/P709

[18] P1095: https://wg21.link/P1095

[19] P1028: https://wg21.link/P1028

[20] тестовой реализации: https://github.com/ned14/status-code

[21] Источник: https://habr.com/post/430690/?utm_campaign=430690