- PVSM.RU - https://www.pvsm.ru -
Странно, что на Хабре до сих пор не было упомянуто о наделавшем шуму предложении к стандарту 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].
Контракты [6] (не путать с концептами) — новый способ наложить ограничения на параметры функции, добавленный в C++20. Добавлены 3 аннотации:
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];
}
Можно настроить, чтобы нарушение контрактов:
std::terminate
Продолжать работу программы после нарушения контракта никак нельзя, потому что компиляторы используют гарантии из контрактов для оптимизации кода функции. Если есть малейшее сомнение в том, что контракт выполнится, стоит добавить дополнительную проверку.
Библиотека <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
и выбросить.
Пусть вам нужно создать очередной класс объектов, владеющих какими-нибудь ресурсами. Скорее всего, вы захотите сделать его некопируемым, но перемещаемым (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 с точки зрения компилятора, если выполняется одно из следующих (рекурсивных) условий:
int
или POD структура)[[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 году. Были предложены различные варианты реализации. Из них в итоге был выбран механизм таблиц исключений, которые гарантируют отсутствие оверхеда для основного пути выполнения программы. Потому что с самого момента их создания создания предполагалось, что исключения должны выбрасываться очень редко.
Недостатки динамических (то есть обычных) исключений:
noexcept
. Исключение может быть выброшено практически в любом месте программы, даже там, где автор функции его не ожидаетИз-за этих недостатков существенно ограничивается область применения исключений. Когда исключения не могут применяться:
По опросам, на местах работы 52% (!) разработчиков исключения запрещены корпоративными правилами.
Но исключения — неотъемлемая часть C++! Включая флаг -fno-exceptions
, разработчики теряют возможность использовать значительную часть стандартной библиотеки. Это дополнительно подстрекает компании насаждать собственные "стандартные библиотеки" и да, изобретать свой класс строки.
Но и это ещё не конец. Исключения — единственный стандартный способ отменить создание объекта в конструкторе и выдать ошибку. Когда они отключены, появляется такая мерзость, как двухфазная инициализация. Операторы тоже не могут использовать коды ошибок, поэтому они заменяются функциями вроде assign
.
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
), иначе все функции будут резервировать больше места на стеке.
Библиотека <system_error2>
, разработанная Niall Douglas, будет содержать status_code<T>
— «новый, лучший» error_code
. Основные отличия от error_code
:
status_code
— шаблонный тип, который можно использовать для хранения практически любых мыслимых кодов ошибок (вместе с указателем на status_code_category
), без использования статических исключенийT
должен быть Trivially relocatable и копируемым (последнее, ИМХО, не должно быть обязательным). При копировании и удалении вызываются виртуальные функции из status_code_category
status_code
может хранить не только данные об ошибке, но и дополнительные сведения об успешно завершённой операции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)) {}
Тип исключения по умолчанию (throws
без типа), а также базовый тип исключений, к которому приводятся все остальные (вроде std::exception
) — это error
. Он определён примерно так:
using error = errored_status_code<intptr_t>;
То есть error
— это такой «ошибочный» status_code
, у которого значение (value
) помещается в 1 указатель. Так как механизм status_code_category
обеспечивает корректное удаление, перемещение и копирование, то теоретически в error
можно сохранить любую структуру данных. На практике это будет один из следующих вариантов:
std::exception_handle
, то есть указатель на выброшенное динамическое исключение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, причём эти исключения будут 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++20noexcept
— спецификатор функции, функция не бросает динамические исключенияnoexcept(expression)
— спецификатор функции, функция не бросает динамические исключения при условииnoexcept(expression)
— бросает ли выражение динамические исключения?throws(E)
— спецификатор функции, функция бросает статические исключенияthrows
= throws(std::error)
fails(E)
— функция, импортированная из C, бросает статические исключенияИтак, в C++ завезли (точнее, завезут) тележку новых инструментов для обработки ошибок. Далее возникает логичный вопрос:
Ошибки разделяются на несколько уровней:
vector::at()
.std::optional
, std::expected
, std::variant
. Примеры: stoi()
[15]; vector::find()
; map::insert
[16].В стандартной библиотеке надёжнее всего будет полностью отказаться от использования динамических исключений, чтобы сделать компиляцию «без исключений» легальной.
Функции, использующие errno
для быстрой и минималистичной работы с кодами ошибок C и C++, должны быть заменены на fails(int)
и throws(std::errc)
, соответственно. Некоторое время старый и новый варианты функций стандартной библиотеки будут сосуществовать, потом старые объявят deprecated.
Ошибки выделения памяти обрабатывает глобальный хук new_handler
, который может:
Сейчас по умолчанию выбрасывается 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 сейчас находится в состоянии черновика. Он уже довольно сильно поменялся, и ещё может сильно измениться. Некоторые наработки не успели опубликовать, так что предлагаемый API <system_error2>
не совсем актуален.
Предложение описывается в 3 документах:
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
Нажмите здесь для печати.