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

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

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

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

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

double safe_divide(int x, int y) throws(arithmetic_error) 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 предлагает ряд подобных функций:

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

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

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

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

По умолчанию, в expected хранится «результат». Тип expected похож на variant, но предоставляет удобный интерфейс для работы с «результатом» и «ошибкой». Реализация 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 (без поддержки optional<T&>), в C++20 добавили expected.

Contracts

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

  • 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

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

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

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

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

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 на код SUCCESS, и наоборот. Важно, что в std::error_code значение 0 означает отсутствие ошибки.

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

Destructive move / Trivially relocatable

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

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

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

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

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

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

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

Предлагается пометить как [[trivially_relocatable]] большинство типов стандартной библиотеки, включая std::string, std::vector, std::unique_ptr. Использовать эту информацию можно с помощью std::uninitialized_relocate, которая исполняет move init + delete обычным способом, или ускоренным, если это возможно. Оверхед 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, Qt). Если в коде есть хоть одна не exception-safe функция, то по закону подлости через неё рано или поздно прокинут исключение и создадут баг
  5. В компаниях, набирающих программистов, которые понятия не имеют об exception safety

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

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

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

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

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

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

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

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

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

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

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

status_code

Основные отличия от error_code: Библиотека <system_error2>, разработанная Найл Дуглас, будет содержать status_code<T> — «новый, лучший» 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>, или ещё какой-нибудь сумасшедший способ владения строкой. Найл утверждает, что #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>;

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

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

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

Если динамическое исключение выбрасывается из «статической» функции, то оно заворачивается в error. throws подразумевает noexcept. Пример: Если статическое исключение выбрасывается из «динамической» функции, то оно заворачивается в исключение 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?!

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

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

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

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

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

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

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

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

errno

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

Out of memory

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

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

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

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

logic_error

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

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

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

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

  1. P709 — первоначальный документ от Герба Саттера
  2. P1095 — детерминированные исключения в видении Найла Дугласа, некоторые моменты изменены, добавлена совместимость с языком C
  3. P1028 — API из тестовой реализации std::error

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

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

Заключение

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

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


Оставить комментарий

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

*

x

Ещё Hi-Tech Интересное!

Теория счастья. Случайности неслучайны

Продолжаю знакомить читателей Хабра с главами из своей книжки «Теория счастья» с подзаголовком «Математические основы законов подлости». Это ещё не изданная научно-популярная книжка, очень неформально рассказывающая о том, как математика позволяет с новой степенью осознанности взглянуть на мир и жизнь ...

Снежинки в стилистике StarWars своими руками (upd. 2018)

Для изготовления снежинок вам потребуется: Файл со схемой.2. 1. Ножницы.4. Принтер (думаю лазерный будет предпочтительней).3. Бумага.5. Скальпель.5. Лично мое мнение — при печати на стандартной офисной бумаге плотностью 80г/м2 становится не очень удобно вырезать (все-таки при складывании бумаги получается толстый ...