О чём тут не будет: напоминания базовых конструкций языка и основных моментов о том, как с ними работать; подробного разбора, как работают исключения (писали тут и тут); как грамотно спроектировать ваш класс/программу, чтобы не наломать дров в будущем с гарантией исключений (разве что совсем чуть-чуть, хотя я сам и не очень-то тук-тук).
О чём будет: разные способы обработки ошибок в C++, несколько советов от сообщества и немного заметок о различных жизненных (и не очень) ситуациях.
Текущее состояние дел
Перед тем, как посмотреть, что же есть в C++, давайте вспомним, как с ошибками жили C-программисты. Тут есть несколько опций:
возвращать код ошибки. Например заранее определить enum с возможными кодами ошибок:
enum err { OK = 0, UNEXPECTED };
err func(int x, int** result);
использовать thread-local значения вроде
errno
(для windowsGetLastError
):
передавать отдельную переменную для ошибки:
int* func(int x, err* errcode);
Почему этого недостаточно? Код возврата/параметр очень легко проигнорировать. Как часто вы проверяли, что вернули scanf
/printf
? Установку errno
ещё легче.
Из-за этих (и ряда других) причин в С++ появились исключения. Их преимущества:
код не замусоривается обработкой кодов ошибок. Обработка исключений более менее отделена от логики приложения (если не говнокодить) + на каждый код возврата у вас нет лишнего бранча, который иногда может быть не очень просто предсказать;
исключения сложно игнорировать.
И недостатки:
flow кода может быть непредсказуем;
некоторый оверхед на поддержку исключений. Причём он есть, даже если вы исключения не используете (и не сделали что-то для того, чтобы его не было).
Кроме исключений ещё есть продвинутые коды возвратов. Тут не только значения, но и категории значений, чтобы можно было проверять, относится ли код к какой-то группе (прям как ловить базовый класс исключения вместо конкретных наследников):
std::error_code ec { MY_ERRC, std::errc::not_enough_memory};
...
if (ec == std::errc::not_enough_memory) {…}
Спорить о том, что же удобнее и эффективнее, – не самое продуктивное занятие. В языке есть оба инструмента, которые нужно применять исходя из ваших нужд и требований (даже Bjarne Stroustrup писал, что исключения не замена другим возможным техникам обработки). Самый простой пример – исполнение в constexpr-контексте. При выполнении кода с бросанием исключений в constexpr-контексте вы получите ошибку компиляции (это даже как чит используется). Однако вы можете захотеть уметь в compile time обрабатывать ошибки. Тут вам и помогут коды возвратов. Только не std::error_code:
эти ребята в constexpr
не умеют.
Ещё, грубо говоря, std::optional
тоже своего рода механизм обработки ошибок, но семантически его часто используют не для исключительных ситуаций, а для приемлемых ситуаций. Так что well yes but actually no.
Светлое будущее
Следующим шагом для стандартного C++ является пропозал по введению std::expected<T, E>
(аналог Result<T, E>
из Rust). Здесь возвращается либо результат, либо сконструированное исключение (или std::error_code
, int
, MyErrorClass
и что угодно ещё). Есть хороший доклад Andrei Alexandrescu на CppCon2018 про это. Можно посмотреть вариант базовой реализации.
Всё новое хорошо забытое старое…
Вообще подобные штуки можно было делать и раньше, например с помощью std::exception_ptr
, std::current_exception
и std::rethrow_exception
. Ловите ваше исключение и работаете с ним, как объектом, пока не нужно бросить его дальше. Но идея std::expected
это всё-таки уровень повыше: у вас всегда пара значений, в которой есть только что-то одно.
Мне нравятся варианты с корутинами, если не обращать внимания на неприятные глазу приставки co_ к половине операторов. Например такой, где они совмещаются со std::expected
и всё это варится в виде монад, что позволяет напрямую не обрабатывать ошибки без необходимости:
struct error {
int code;
};
expected<int, error> f1() { return 7; }
expected<double, error> f2(int x) { return 2.0 * x; }
expected<int, error> f3(int x, double y) { return error{42}; }
auto test_expected_coroutine() {
return []() -> expected<int, error> {
auto x = co_await f1();
auto y = co_await f2(x);
auto z = co_await f3(x, y);
co_return z;
}();
}
Или вот замечательный доклад про подобное в другом виде. Хотя конечно на практике такое не очень используется, потому что могут быть проблемы с производительностью.
Рядом с пропозалом о std::expected
ещё есть пропозал об operator try()
(что-то вроде operator ?
из Rust), который помогает писать меньше кода. Автор предлагает ввести понятную конструкцию, чтобы не приходилось абузить корутины для достижения таких же результатов. Правда она в перспективе не дойдёт до стандарта до C++29.
Самой конфетой является предложение Herb Sutter про использование статических исключений. Пример из пропозала:
string f() throws {
if (flip_a_coin()) throw arithmetic_error::something;
return “xyzzy”s + “plover”; // any dynamic exception is translated to error
}
string g() throws { return f() + “plugh”; } // any dynamic exception is translated to error
int main() {
try {
auto result = g();
cout << “success, result is: ” << result;
}
catch(error err) { // catch by value is fine
cout << “failed, error is: ” << err.error();
}
}
Появляется новое ключевое слово throws
, которое означает, что функция возвращает на самом деле (грубо говоря) std::expected<T, error_code>
, а все throw
в функции -- на самом деле return
, который возвращает код ошибки. И теперь можно будет писать всегда либо throws
, либо noexcept
. Ещё тут предлагается расширить кейсы использования ключевого слова try
: использовать вместе с/вместо return
, при инициализации, использовать при передаче аргументов функций. Немного синтаксического сахара при использовании catch
. А ещё предлагаемая модель является real-time safe (это когда время работы инструмента/механизма ограничено сверху известной величиной) в отличие от текущей реализации исключений. Однако работа над этим пропозалом не велась с 2019, и что с ним и как непонятно.
Как альтернатива есть статья James Renwick о другой реализации такого же механизма, как у Herb Sutter, но она подразумевает слом ABI, что почти наверняка в ближайшие годы не случится.
Набросы
Часто считается плохой практикой бросать что-то не унаследованное от стандартных ошибок. И тут (как и со своими типами) стоит быть аккуратным:
struct e1 : std::exception {};
struct e2 : std::exception {};
struct e3 : ex1, ex2 {};
int main() {
try { throw_e3(); }
catch(std::exception& e) {}
catch(...) {}
}
Т.к. у e3
несколько предков std::exception
=> компилятор не сможет понять, к какому именно std::exception
нужно привести объект e3
, потому это исключение будет отловлено в catch(...)
. При виртуальном наследовании e1
, e2
от std::exception
всё работает как ожидается.
Знатные маслины можно ловить при бросании исключений откуда не надо. Например, у стандартной библиотеки есть некоторые инварианты, без которых написание кода стало бы ужасной мукой (а может и вовсе невозможным). Одним из них является предположение, что деструкторы, операции удаления и swap не бросают исключений, потому хорошо бы помечать их noexcept
. Если по каким-то причинам внутри что-то может вылететь, на месте (прям до выхода из функции/методов) ловите исключения и пытайтесь исправить ситуацию, чтобы состояние программы осталось валидным. По-хорошему ещё и move-операции должны быть небросающими, т.к. это открывает путь к более эффективному коду (классический пример это использование std::move_if_noexcept
в std::vector
).
Собственно с деструкторами и начинается самый флекс: если исключение вылетает при раскрутке стека, вы сразу ловите std::terminate
. Бороться с такими проблемами можно разными способами. Самый хороший – не бросать исключения из деструкторов. Если очень хочется, юзайте noexcept(false)
, но лучше отбросьте эти богохульные мысли и идите спать. Чуть больше про это можно почитать вот тут.
Интересные штуки ещё можно делать со статическими переменными. Во-первых, их инициализация происходит атомарно. Во-вторых, только один раз. Т.е. если вы хотите выполнить какой-то единожды, вы можете сделать следующее:
[[maybe_unused]] static bool unused = [] {
std::cout << "printed once" << std::endl;
return true;
}();
А что, если хочется выполнить какой-то код ровно n раз? Тут можно воспользоваться фактом, что, если при инициализации вылетает исключение, переменная не инициализируется и попытается инициализироваться в следующий раз:
struct Throwed {};
constexpr int n = 3;
void init() {
try {
[[maybe_unused]] static bool unused = [] {
static int called = 0;
std::cout << "123" << std::endl;
if (++called < n) {
throw Throwed{};
}
return true;
}();
} catch (Throwed) {}
}
Но это тоже говнокод ¯\_(ツ)_/¯.
Какие-то рекомендации
Набросы из личного опыта и советов из интернетов, которые, к сожалению, получилось прочувствовать на себе:
-
Исключения задумывались в мире, где существуют деструкторы, а значит и RAII. Используйте эту идиому максимально, если речь идёт об освобождении ресурсов.
Если для ситуации RAII подходит недостаточно (нужно совершить не очистку ресурсов, а просто набор действий), сообразите что-то вроде gsl::finally.
Используйте исключения, если в конструкторе объекта становится понятно, что объект создать невозможно (раз, два). Тут так-то других вариантов особо и нет: возвращаемое значение у конструкторов не предусмотрено. Можно конечно завести условный метод
IsValid
и обмазаться конструкциями сif
, но имхо не оч удобно.Можно использовать исключения для проверки пред-/постусловий.
В силу непредсказуемости flow выполнения вашего кода из-за исключений, можно с ними знатные приколы мутить. Встречались кейсы, когда исключения использовались для выхода из глубокой рекурсии, нескольких циклов сразу или, внезапно, даже возврата значения из функции. Не делайте так. Исключения они на то и исключения, чтобы детектить ошибки. Exceptions are for exceptional.
Но не переусердствуйте с ловлей исключений. Хорошо, когда вы ожидаете какую-то конкретную ошибку и ловите именно её. Думаю, вы тоже видели код с конструкциями вида
catch (...) {}
, потому что “ну там какие-то исключения вылетают, а падать не хочется”. Разберитесь с этим и контролируйте (может у вас есть действительно хорошие примеры, где это наилучшее решение; тогда расскажите в комментариях).Если не можете обработать исключение, делайте аборт (
std::abort
/std::terminate
/std::exit
/std::quick_exit
).Старайтесь ловить исключения так, чтобы они копировались минимальное количество раз (с помощью ссылок/указателей/
exception_ptr
). В идеале ноль.
Ещё немного набросов
В некоторых проектах исключения вообще стараются не использовать, т.к. это не очень эффективно (размотка стека и проблемы с некоторыми оптимизациями). В таких случаях применяются другие подходы обработки ошибок (например падение). Тут же есть практики постоянно писать noexcept
. Это хорошая практика, но всё же стоит быть осторожным, т.к. это часть интерфейса. Короче пользуйтесь с умом.
Если вы точно не хотите использовать исключения, можно компилировать ваш проект с -fno-exceptions
, что позволяет не поддерживать исключения при компиляции -> открыть возможности для новых оптимизаций (будьте готовы к разным неожиданным эффектам; например стандартная библиотека станет падать там, где раньше вылетали исключения).
Вы можете использовать function-try-block для ловли исключений из всей функции/конструкторов со списками инициализации:
struct S {
MyClass x;
S(MyClass& x) try : x(x) {
} catch (MyClassInitializationException& ex) {...}
};
Но имейте в виду некоторые возможные проблемы.
Мне нравится как принято работать с ошибками в Golang: вы словили её, добавили к сообщению какую-то информацию и бросили дальше, чтобы в итоге сообщение у ошибки получилось примерно такое: “topFunc: secondFunc: firstFunc: some error text”
. Довольно удобно (по крайней мере в Go), если у вас похожая парадигма работы с ошибками и нет stacktrace рядом с исключениями. Однако в C++ стоит быть осторожным, потому что есть механизм std::throw_with_nested, который совсем о другом. Концептуально тут всё просто: у исключений может быть вложенное исключение, которое можно достать из родительского исключения. Получается, можно сделать дерево в виде цепочки из исключений (прямо как в Java есть cause у исключений, но там этот механизм чуть шире и делать так принято). Имхо если вы такое используете, у вас какие-то архитектурные проблемы, так что перед написанием новых велосипедов, задумайтесь, всё ли в порядке.
Бесполезный (но забавный) факт. Вот такой код вполне себе корректен: throw nothrow
.
Несмешная нешутка.
*шутка про то, что C++ – ошибка, которую не сумели правильно обработать*
Реклама.
Можете подписаться на канал о C++ и программировании в целом в тг: t.me/thisnotes.
Комментарии (20)
mapron
26.09.2022 00:57потому хорошо бы помечать их noexcept
начиная с C++11 если вы не объявили деструктор throw(...) / noexcept(..), он и так считается noexcept. Это кстати breaking change было, у кого-то даже что-то ломалось от этого :D
я работал с легаси кодовой базой где были исключения в деструкторах, но я не дожил до ее миграции на С++11.
Sklott
26.09.2022 13:22Думаю, вы тоже видели код с конструкциями вида
catch (...) {}
, потому что “ну там какие-то исключения вылетают, а падать не хочется”.Мне кажется это вполне приемлимый случай, если мы говорим о "неожиданных" исключениях (читай, багах), и при этом мы можем выйти после такого исключения в какое-то определёенное состояние. Ну например прибить/перезапустить какой-то один модуль, от чего не пострадает остальная система. И когда альтернативой по сути является полный крэш системы.
Вообще обработка ошибок на самом деле довольно недооцененная проблема. По сути ведь в системе всегда есть ошибки нескольких разных типов, реагировать на которые надо по-разному. Но при этом по сути, почти всегда, предлагается для всех них использовать одни и те-же механизмы. Мне кажется отсюда большая часть проблем и вылазит.
Dasfex Автор
26.09.2022 13:42Согласен. Абстрактную ситуацию представить легко. И не исключаю, что это где-то применимо. Но пока ещё не видел реально хорошего кейса. Всегда код можно было бы порефакторить в лучшую сторону.
hoxnox
26.09.2022 13:42Довольно давно таскаю с собой вот такой вот header-only helper
Пример:
E<int> foo() { if (something_wrong()) return Error() << "something happened"; return 100; } Error bar() { if (auto rs = foo(); !rs) return Error() << "foo error: " << error(rs); return {} } ... auto rs = foo(); if (!rs) return; auto& value = value(rs); auto rs = bar(); if (!rs) { std::cout << rs; } int c = *foo();
AnthonyMikh
26.09.2022 18:33+1Во-первых, ссылка битая. Во-вторых, чем это отличается от expected?
hoxnox
26.09.2022 18:47Да, простите, дал ссылку на private репозиторий. Выложил в gists:
https://gist.github.com/hoxnox/2a877a810aae21c7e0f523160678abf0
Идеологически действительно очень похоже на expected. Небольшие отличия. Но это уже работает - достаточно подключить хидер-файл.Sklott
27.09.2022 11:51IMHO! Сойдет для мелких и пет проектов, но вряд ли подойдет для больших проектов. Проблема в том, что
std::stringstream
уж больно жирный, например в GCC - 392 байта.Думаю было бы оптимальней использовать просто
std::string
, т.к. дешевле перевыделять динамическую память в исключительных случаях, ведь писать мы туда будем только при ошибках, чем постоянно (в худшем случае на вызов каждой функции) отжирать по 400 байт памяти. А самstd::string
, весит обычно не много, в том-же GCC всего 32 байта.
Sklott
27.09.2022 15:10+1Кстати придумалось еще более простое решение. Надо просто в классе
Error
сделать переменнуюmsg_
указателем, а не значением. Тогда, пока у нас нет ошибки весь этот класс занимает всего 4 байта, что по сути и так минимально возможный размер объекта для 64-битных архитектур. А если уж случилась ошибка, то можно и заалоцировать такой большой класс какstd::stringstream
.
slonopotamus
27.09.2022 16:54Исключения задумывались в мире, где существуют деструкторы, а значит и RAII.
Угу, при этом исключение в деструкторе - ггвп.
slonm
28.09.2022 11:26+1Исключения они на то и исключения, чтобы детектить ошибки. Exceptions are for exceptional.
<sarcasm>Именно поэтому исключения названы исключениями, а не ошибками?</sarcasm>
slonopotamus
[[nodiscard]]
+-Werror
и вот уже не очень легко проигнорировать код возврата.staticmain
Если речь про С
Alexey_Sharapov
В C23 добавят [[nodiscard]] и [[maybe_unused]]. И gcc, и clang позволяют уже сейчас включить C23.
Dasfex Автор
[[nodiscard]]
это C++11. Исключения же проектировались раньше.По поводу штук вроде
__attribute__((warn_unused_result))
. Насколько я знаю, это расширения компиляторов -> писать кроссплатформенный код становится гораздо неприятнее.mapron
C++17 атрибут. (не важно особо, так, придираюсь)
Не очень понял в контексте «исключения проектировались раньше», ну да, это середина 90-х, вроде еще до шаблонов, как это связано с тем что в статье обсуждаются сегодняшние способы решения (и возможное завтра?)
со slonopotamus согласен, если зацепиться только за «легко проигнорировать» это ну неплохое решение (остается не забывать втыкать атрибут, что тут поделать).
Расширения, хз зачем, nodiscard давно поддерживается. Если рабоаешь в проекте где не завезли 17 плюсы, там куда больше проблем и болей чем «как бы тут клево ошибки обработать»
Dasfex Автор
Ну я скорее конкретно этот момент прокомментировал. Понятно, что это не единственная проблема. Например ещё код обработки сильно переплетался с бизнес-логикой. И разные другие неприятности.
Очевидно с таким способом можно жить, если немножко сознательности проявлять. В go вот живут.