
Существует две фундаментальные стратегии: обработка исправимых ошибок (исключения, коды возврата по ошибке, функции-обработчики) и неисправимых ( assert()
, abort()
). В каких случаях какую стратегию лучше использовать?
О чём тут не будет: напоминания базовых конструкций языка и основных моментов о том, как с ними работать; подробного разбора, как работают исключения (писали тут
и тут
); как грамотно спроектировать ваш класс/программу, чтобы не наломать дров в будущем с гарантией исключений (разве что совсем чуть-чуть, хотя я сам и не очень-то тук-тук).
О чём будет: разные способы обработки ошибок в C++, несколько советов от сообщества и немного заметок о различных жизненных (и не очень) ситуациях.
задался вопросом, а все таки как же “правильно” отлавливать ошибки в функции точней как правильно возвращать результат выполнения функции , если в процессе произошла ошибка.
Например:
Есть форма на ней datagirdview и и кнопка button1
Для удобности я создаю отдельный файл класса и всю “математику” стараюсь держать там, соответственно я изолирован от работы непосредственно с контролами формы в основном потоке.
Создаем функцию которая отправляет запрос на rest и в идеале должна получить ответ в json и вернуть список List с нужной нам инфой
class Regions{
public string id{get;set}
public string title{get;set}
}
public static List<Regions> GetRegions(){
try{
WebRequest link = WebRequest.Create(@"link")
....
}
Далее получаем ответ, создаем экз. класса, парсим json,заполняем его и возвращаем.
А теперь сам вопрос:
Если по како то причине при запросе возникнет ошибка тогда мне нужно вернуть пустой экземпляр класса либо заполнить его с учетом ошибки. На самой форме тогда тоже придется это проверять.
Как правильно и красиво реализовать эту проверку?
Наиболее подходящим вариантом будет предоставить дополнительную функцию или переменную, откуда можно получить код/текст последней ошибки. Существующие варианты такого подхода это errno
, GetLastError
и т.п. В этом случае, пользователь библиотеки может сам решить куда ему следует (и следует ли вообще) выводить сообщение об ошибке.
Отсюда же вытекает и решение о недопустимости вызова exit
из библиотечного кода. По крайней мере, если нет соответствующей функции в библиотеке, которая меняет это поведение (по умолчанию никаких активных exit
быть не должно). Пользователю может быть вообще хочется наплевать на обработку ошибок и просто работать дальше, а библиотека рушит ему всё приложение из N потоков на корню.
Возвращать из каждой функции признак успешности её выполнения через return
может быть не совсем удобно, т.к. в первую очередь функция должна возвращать то, на что она заточена, а не признак ошибки или код возврата. Для примера, представьте, как будет выглядеть функция типа strlen
в таком случае и насколько удобно будет ей пользоваться при этом.
В любом случае, способы оповещения пользователя библиотеки об ошибочных ситуациях должны быть чётко прописаны в руководстве.
Проблема
Я работаю над проектом foonathan/memory
. Это решение предоставляет различные классы выделения памяти (allocator classes), так что в качестве примера рассмотрим структуру функции выделения.
Для простоты возьмём malloc()
. Она возвращает указатель на выделяемую память. Если выделить память не получается, то возвращается nullptr
, то есть NULL
, то есть ошибочное значение.
У этого решения есть недостатки: вам нужно проверять каждый
вызов malloc()
. Если вы забудете это сделать, то выделите несуществующую память. Кроме того, по своей натуре коды ошибок транзитивны: если вызвать функцию, которая может вернуть код ошибки, и вы не можете его проигнорировать или обработать, то вы тоже должны вернуть код ошибки.
Это приводит нас к ситуации, когда чередуются нормальные и ошибочные ветви кода. Исключения в таком случае выглядят более подходящим решением. Благодаря им вы сможете обрабатывать ошибки только тогда, когда вам это нужно, а в противном случае — достаточно тихо передать их обратно вызывающему.
Это можно расценить как недостаток.
Но в подобных ситуациях исключения имеют также очень большое преимущество: функция выделения памяти либо возвращает валидную память, либо вообще ничего не возвращает. Это функция «всё или ничего», возвращаемое значение всегда будет валидным. Это полезное следствие согласно принципу Скотта Майера « Make interfaces hard to use incorrectly and easy to use correctly
».
Учитывая вышесказанное, можно утверждать, что вам следует использовать исключения в качестве механизма обработки ошибок. Этого мнения придерживается большинство разработчиков на С++, включая и меня. Но проект, которым я занимаюсь, — это библиотека, предоставляющая средства выделения памяти, и предназначена она для приложений, работающих в реальном времени. Для большинства разработчиков подобных приложений (особенно для игроделов) само использование исключений — исключение.
Чтобы уважить эту группу разработчиков, моей библиотеке лучше обойтись без исключений. Но мне и многим другим они нравятся за элегантность и простоту обработки ошибок, так что ради других разработчиков моей библиотеке лучше использовать исключения.
Так что же делать?
Идеальное решение: возможность включать и отключать исключения по желанию. Но, учитывая природу исключений, нельзя просто менять их местами с кодами ошибок, поскольку у нас не будет внутреннего кода проверки на ошибки — весь внутренний код опирается на предположение о прозрачности исключений. И даже если бы внутри можно было использовать коды ошибок и преобразовывать их в исключения, это лишило бы нас большинства преимуществ последних.
К счастью, я могу определить, что вы делаете, когда обнаруживаете ошибку нехватки памяти: чаще всего вы журналируете это событие и прерываете программу, поскольку она не может корректно работать без памяти. В таких ситуациях исключения — просто способ передачи контроля другой части кода, которая журналирует и прерывает программу. Но есть старый и эффективный способ передачи контроля: указатель функции (function pointer), то есть функция-обработчик (handler function).
Если у вас включены исключения, то вы просто их бросаете. В противном случае вызываете функцию-обработчика и затем прерываете программу. Это предотвратит бесполезную работу функции-обработчика, та позволит программе продолжить выполняться в обычном режиме. Если не прервать, то произойдёт нарушение обязательного постусловия функции: всегда возвращать валидный указатель. Ведь на выполнении этого условия может быть построена работа другого кода, да и вообще это нормальное поведение.
Я называю такой подход обработкой исключений и придерживаюсь его при работе с памятью.
Об иерархии std
Если в качестве стратегии обработки исправимых ошибок вы выбрали исключения, то рекомендуется создать новый класс и наследовать его от одного из классов исключений стандартной библиотеки.
Я предлагаю наследовать только от одного из этих четырёх классов:
-
std::bad_alloc
: для сбоев выделения памяти. -
std::runtime_error
: для общих runtime-ошибок. -
std::system_error
(производное отstd::runtime_error
): для системных ошибок с кодами ошибок. -
std::logic_error
: для программистских ошибок с определённым поведением.
Обратите внимание, что в стандартной библиотеке разделяются логические (то есть программистские) и runtime-ошибки. Runtime-ошибки — более широкое определение, чем «системные». Оно описывает «ошибки, обнаруживаемые только при выполнении программы». Такая формулировка не слишком информативна. Лично я использую её для плохих параметров, которые не являются исключительно программистскими ошибками, а могут возникнуть и по вине пользователей. Но это можно определить лишь глубоко в стеке вызовов. Например, плохое форматирование комментариев в standardese
приводит к исключению при парсинге, проистекающему из std::runtime_error
. Позднее оно ловится на соответствующем уровне и фиксируется в логе. Но я не стал бы использовать этот класс иначе, как и std::logic_error
.
System_error
Подобная система идеально подходит для работы с кодами ошибок в С++ 11.
Она возвращает непортируемый (non-portable) код ошибки std::error_code
, то есть возвращаемый функцией операционной системы. С помощью сложной системы библиотечных средств и категорий ошибок вы можете добавить собственные коды ошибок, или портируемые std::error_condition
. Для начала почитайте об этом здесь
. Если нужно, то можете использовать в функции кода ошибки std::error_code
. А для функции исключения есть подходящий класс исключения: std::system_error
. Он берёт std::error_code
и применяется для передачи этих ошибок в виде исключений.
Эту или подобную систему должны использовать все низкоуровневые функции, являющиеся закрытыми обёртками ОС-функций. Это хорошая — хотя и сложная — альтернатива службе кодов ошибок, предоставляемой операционной системой.
Да, и мне ещё нужно добавить подобное в функции виртуальной памяти. На сегодняшний день они не предоставляют коды ошибок.
Ещё немного набросов
В некоторых проектах исключения вообще стараются не использовать, т.к. это не очень эффективно (размотка стека и проблемы
с некоторыми оптимизациями). В таких случаях применяются другие подходы обработки ошибок (например падение). Тут же есть практики постоянно писать noexcept
. Это хорошая практика, но всё же стоит быть осторожным, т.к. это часть интерфейса. Короче пользуйтесь с умом.
Если вы точно не хотите использовать исключения, можно компилировать ваш проект с -fno-exceptions
, что позволяет не поддерживать исключения при компиляции -> открыть возможности для новых оптимизаций (будьте готовы к разным неожиданным эффектам; например стандартная библиотека станет падать там, где раньше вылетали исключения).
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++ – ошибка, которую не сумели правильно обработать
*
Реклама.
Набросы
Часто считается плохой практикой бросать что-то не унаследованное от стандартных ошибок. И тут (как и со своими типами) стоит быть аккуратным:
struct e1 : std::exception {};
struct e2 : std::exception {};
struct e3 : ex1, ex2 {};
int main() {
try { throw_e3(); }
catch(std::exception& e) {}
catch(...) {}
}
Знатные маслины можно ловить при бросании исключений откуда не надо. Например, у стандартной библиотеки есть некоторые инварианты, без которых написание кода стало бы ужасной мукой (а может и вовсе невозможным). Одним из них является предположение, что деструкторы, операции удаления и 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) {}
}
Но это тоже говнокод ¯\_(ツ)_/¯.
Виды ошибок
Ошибки возникают по разным причинам: пользователь ввёл странные данные, ОС не может дать вам обработчика файла или код разыменовывает (dereferences) nullptr
. Каждая из описанных ошибок требует к себе отдельного подхода. По причинам ошибки делятся на три основные категории:
- Пользовательские ошибки:
здесь под пользователем подразумевается человек, сидящий перед компьютером и действительно «использующий» программу, а не какой-то программист, дёргающий ваш API. Такие ошибки возникают тогда, когда пользователь делает что-то неправильно. - Системные ошибки
появляются, когда ОС не может выполнить ваш запрос. Иными словами, причина системных ошибок — сбой вызова системного API. Некоторые возникают потому, что программист передал системному вызову плохие параметры, так что это скорее программистская ошибка, а не системная. - Программистские ошибки
случаются, когда программист не учитывает предварительные условия API или языка программирования. Если API требует, чтобы вы не вызывалиfoo()
с0
в качестве первого параметра, а вы это сделали, — виноват программист. Если пользователь ввёл0
, который был переданfoo()
, а программист не написал проверку вводимых данных, то это опять же его вина.
Каждая из описанных категорий ошибок требует особого подхода к их обработке.
Системные ошибки
Обычно системные ошибки нельзя предсказать. Более того, они недетерминистские и могут возникать в программах, которые до этого работали без нареканий. В отличие от пользовательских ошибок, зависящих исключительно от вводимых данных, системные ошибки — настоящие ошибки.
Но как их обрабатывать, как исправимые или неисправимые?
Это зависит от обстоятельств.
Многие считают, что ошибка нехватки памяти — неисправимая. Зачастую не хватает памяти даже для обработки этой ошибки! И тогда приходится просто сразу же прерывать выполнение.
Но падение программы из-за того, что ОС не может выделить сокет, — это не слишком дружелюбное поведение. Так что лучше бросить исключение и позволить catch
аккуратно закрыть программу.
Но бросание исключения — не всегда правильный выбор.
Кто-то даже скажет, что он всегда
неправильный.
Если вы хотите повторить операцию после её сбоя, то обёртывание функции в try-catch
в цикле — медленное
решение. Правильный выбор — возврат кода ошибки и цикличное исполнение, пока не будет возвращено правильное значение.
Если вы создаёте вызов API только для себя, то просто выберите подходящий для своей ситуации путь и следуйте ему. Но если вы пишете библиотеку, то не знаете, чего хотят пользователи. Дальше мы разберём подходящую стратегию для этого случая. Для потенциально неисправимых ошибок подойдёт «обработчик ошибок», а при других ошибках необходимо предоставить два варианта развития событий.
Обратите внимание, что не следует использовать подтверждения (assertions), включающиеся только в режиме отладки. Ведь системные ошибки могут возникать и в релизной сборке!
Светлое будущее
Следующим шагом для стандартного 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, что почти наверняка в ближайшие годы не случится.
Expected
Выше упоминалось о проблеме, когда у вас нет возвращаемого значения, содержащего недопустимое значение, которое можно использовать для сигнализирования об ошибке. Более того, выходной параметр — не лучший способ получения кода ошибки.
А глобальные переменные вообще не вариант!
В № 4109
предложено решение: std::expected
. Это шаблон класса, который также хранит возвращаемое значение или код ошибки. В вышеприведённом примере он мог бы использоваться так:
std::expected<void*, std::error_code> try_malloc(...);
В случае успеха std::expected
будет хранить не-null указатель памяти, а при сбое — std::error_code
. Сейчас эта методика работает при любых возвращаемых значениях. Комбинация std::expected
и функции исключения определённо допускает любые варианты использования.
А если я хочу продолжить работу после бросания исключения?
Методика с обработчиком исключений не позволяет этого сделать в связи с постусловием кода. Как же тогда продолжить работу?
Ответ прост — никак. По крайней мере, это нельзя сделать так же просто, как в других случаях. Нельзя просто так вернуть код ошибки вместо исключения, если функция на это не рассчитана.
Есть только одно решение: сделать две функции. Одна возвращает код ошибки, а вторая бросает исключения. Клиенты, которым нужны исключения, будут использовать второй вариант, остальные — первый.
Извините, что говорю такие очевидные вещи, но ради полноты изложения я должен был об этом сказать.
Для примера снова возьмём функцию выделения памяти. В этом случае я использую такие функции:
void* try_malloc(..., int &error_code) noexcept;
void* malloc(...);
При сбое выделения памяти первая версия возвращает nullptr
и устанавливает error_code
в коде ошибки. Вторая версия не возвращает nullptr
, зато бросает исключение. Обратите внимание, что в рамках первой версии очень легко реализовать вторую:
void* malloc(...)
{
auto error_code = 0;
auto res = try_malloc(..., error_code);
if (!res)
throw malloc_error(error_code);
return res;
}
Не делайте этого в обратной последовательности, иначе вам придётся ловить исключение, а это дорого. Также это не даст нам скомпилировать код без включённой поддержки исключений. Если сделаете, как показано, то можете просто стереть другую перегрузку (overload) с помощью условного компилирования.
Но даже если у вас включена поддержка исключений, клиенту всё равно может понадобиться вторая версия. Например, когда нужно выделить наибольший возможный объём памяти, как в нашем примере. Будет проще и быстрее вызывать в цикле и проверять по условию, чем ловить исключение.
Текущее состояние дел
Перед тем, как посмотреть, что же есть в 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.
Пользовательские ошибки
Сделаю очень громкое заявление: такие ошибки — на самом деле не ошибки.
Все пользователи не соблюдают инструкции. Программист, имеющий дело с данными, которые вводят люди, должен ожидать, что вводить будут именно плохие данные. Поэтому первым делом нужно проверять их на валидность, сообщать пользователю об обнаруженных ошибках и просить ввести заново.
Поэтому не имеет смысла применять к пользовательским ошибкам какие-либо стратегии обработки. Вводимые данные нужно как можно скорее проверять, чтобы ошибок не возникало.
Конечно, такое не всегда возможно. Иногда проверять вводимые данные слишком дорого, иногда это не позволяет сделать архитектура кода или разделение ответственности. Но в таких случаях ошибки должны обрабатываться однозначно как исправимые. Иначе, допустим, ваша офисная программа будет падать из-за того, что вы нажали backspace в пустом документе, или ваша игра станет вылетать при попытке выстрелить из разряженного оружия.
Если в качестве стратегии обработки исправимых ошибок вы предпочитаете исключения, то будьте осторожны: исключения предназначены только для исключительных
ситуаций, к которым не относится большинство случаев ввода пользователями неверных данных. По сути, это даже норма, по мнению многих приложений. Используйте исключения только тогда, когда пользовательские ошибки обнаруживаются в глубине стека вызовов, вероятно, внешнего кода, когда они возникают редко или проявляются очень жёстко. В противном случае лучше сообщать об ошибках с помощью кодов возврата.
Гибкие методики обработки ошибок в C++
Иногда что-то не работает. Пользователи вводят данные в недопустимом формате, файл не обнаруживается, сетевое соединение сбоит, в системе кончается память. Всё это ошибки, и их надо обрабатывать.
Это относительно легко сделать в высокоуровневых функциях. Вы точно знаете, почему
что-то пошло не так, и можете обработать это соответствующим образом. Но в случае с низкоуровневыми функциями всё не так просто. Они не знают, что
пошло не так, они знают лишь о самом факте
сбоя и должны сообщить об этом тому, кто их вызвал.
В C++ есть два основных подхода: коды возврата ошибок и исключения. Сегодня широко распространено использование исключений. Но некоторые не могут / думают, что не могут / не хотят их использовать — по разным причинам.
Я не буду принимать чью-либо сторону. Вместо этого я опишу методики, которые удовлетворят сторонников обоих подходов. Особенно методики пригодятся разработчикам библиотек.
Программистские ошибки
Это худший вид ошибок. Для их обработки я стараюсь сделать так, чтобы мои ошибки были связаны только с вызовами функций, то есть с плохими параметрами. Прочие типы программистских ошибок могут быть пойманы только в runtime, с помощью отладочных макросов (assertion macros), раскиданных по коду.
При работе с плохими параметрами есть две стратегии: дать им определённое или неопределённое поведение.
Если исходное требование для функции — запрет на передачу ей плохих параметров, то, если их передать, это считается неопределённым поведением и должно проверяться не самой функцией, а оператором вызова (caller). Функция должна делать только отладочное подтверждение (debug assertion).
С другой стороны, если отсутствие плохих параметров не является частью исходных требований, а документация определяет, что функция будет бросать bad_parameter_exception
при передаче ей плохого параметра, то передача — это хорошо определённое поведение (бросание исключения или любая другая стратегия обработки исправимых
ошибок), и функция всегда должна это проверять.
Примечание: необязательно бросать исключение, чтобы получилось определённое поведение. Пока это не упомянуто в исходных условиях для функции, это считается определённым. Всё, что прописано в исходных условиях, не должно проверяться функцией, это неопределённое поведение.
Когда нужно проверять только с помощью отладочных подтверждений, а когда — постоянно?
К сожалению, однозначного рецепта нет, решение зависит от конкретной ситуации. У меня есть лишь одно проверенное правило, которому я следую при разработке API. Оно основано на наблюдении, что проверять исходные условия должен вызывающий, а не вызываемый. А значит, условие должно быть «проверяемым» для вызывающего. Также условие «проверяемое», если можно легко выполнить операцию, при которой значение параметра всегда будет правильным. Если для параметра это возможно, то это получается исходное условие, а значит, проверяется только посредством отладочного подтверждения (а если слишком дорого, то вообще не проверяется).
Хотя в ряде случаев это может быть ошибкой.
Обработчик исключений
Если вам нужно обработать ошибку в условиях, когда наиболее распространённым поведением будет «журналировать и прервать», то можно использовать обработчика исключений. Это такая функция-обработчик, которая вызывается вместо бросания объекта-исключения. Её довольно легко реализовать даже в уже существующем коде. Для этого нужно поместить управление обработкой в класс исключений и обернуть в макрос выражение throw
.
Сначала дополним класс и добавим функции для настройки и, возможно, запрашивания функции-обработчика. Я предлагаю делать это так же, как стандартная библиотека обрабатывает std::new_handler
:
class my_fatal_error
{
public:
// тип обработчика, он должен брать те же параметры, что и конструктор,
// чтобы у них была одинаковая информация
using handler = void(*)( ... );
// меняет функцию-обработчика
handler set_handler(handler h);
// возвращает текущего обработчика
handler get_handler();
... // нормальное исключение
};
Поскольку это входит в область видимости класса исключений, вам не нужно именовать каким-то особым образом. Отлично, нам же легче.
Если исключения включены, то для удаления обработчика можно использовать условное компилирование (conditional compilation). Если хотите, то также напишите обычный подмешанный класс (mixin class), дающий требуемую функциональность.
Конструктор исключений элегантен: он вызывает текущую функцию-обработчика, передавая ей требуемые аргументы из своих параметров. А затем комбинирует с последующим макросом throw
:
If```cpp #if EXCEPTIONS #define THROW(Ex) throw (Ex) #else #define THROW(Ex) (Ex), std::abort() #endif
> Такой макрос throw также предоставляется [foonathan/compatiblity](https://github.com/foonathan/compatibility).
Можно использовать его и так:
```cpp
THROW(my_fatal_error(...))
Если у вас включена поддержка исключений, то будет создан и брошен объект-исключение, всё как обычно. Но если поддержка выключена, то объект-исключение всё равно будет создан, и — это важно — только после этого произойдёт вызов std::abort()
. А поскольку конструктор вызывает функцию-обработчика, то он и работает, как требуется: вы получаете точку настройки для журналирования ошибки. Благодаря же вызову std::abort()
после конструктора пользователь не может нарушить постусловие.
Когда я работаю с памятью, то при включённых исключениях у меня также включён и обработчик, который вызывается при бросании исключения.
Так что при этой методике вам ещё будет доступна определённая степень кастомизации, даже если вы отключите исключения. Конечно, замена неполноценная, мы только журналируем и прерываем работу программы, без дальнейшего продолжения. Но в ряде случаев, в том числе при исчерпании памяти, это вполне пригодное решение.
Какие-то рекомендации
Набросы из личного опыта и советов из интернетов, которые, к сожалению, получилось прочувствовать на себе:
-
Исключения задумывались в мире, где существуют деструкторы, а значит и RAII. Используйте эту идиому
максимально, если речь идёт об освобождении ресурсов.Если для ситуации RAII подходит недостаточно (нужно совершить не очистку ресурсов, а просто набор действий), сообразите что-то вроде gsl::finally
. -
Используйте исключения, если в конструкторе объекта становится понятно, что объект создать невозможно ( раз
, два
). Тут так-то других вариантов особо и нет: возвращаемое значение у конструкторов не предусмотрено. Можно конечно завести условный методIsValid
и обмазаться конструкциями сif
, но имхо не оч удобно. -
Можно использовать исключения для проверки пред-
/ постусловий
. -
В силу непредсказуемости flow выполнения вашего кода из-за исключений, можно с ними знатные приколы мутить. Встречались кейсы, когда исключения использовались для выхода из глубокой рекурсии, нескольких циклов сразу или, внезапно, даже возврата значения из функции. Не делайте так. Исключения они на то и исключения, чтобы детектить ошибки
. Exceptions are for exceptional. -
Если не можете обработать исключение, делайте аборт (
std::abort
/std::terminate
/std::exit
/std::quick_exit
). -
Старайтесь ловить исключения так, чтобы они копировались минимальное количество раз (с помощью ссылок/указателей/
exception_ptr
). В идеале ноль.
Подведём итоги
Есть два пути обработки ошибок:
- как исправимые
: используются исключения или возвращаемые значения (в зависимости от ситуации/религии); - как неисправимые
: ошибки журналируются, а программа прерывается.
Подтверждения — это особый вид стратегии обработки неисправимых ошибок, только в режиме отладки.
Есть три основных источника ошибок, каждый требует особого подхода:
- Пользовательские ошибки
не должны обрабатываться как ошибки на верхних уровнях программы. Всё, что вводит пользователь, должно проверяться соответствующим образом. Это может обрабатываться как ошибки только на нижних уровнях, которые не взаимодействуют с пользователями напрямую. Применяется стратегия обработки исправимых ошибок. - Системные ошибки
могут обрабатываться в рамках любой из двух стратегий, в зависимости от типа и тяжести. Библиотеки должны работать как можно гибче. - Программистские ошибки
, то есть плохие параметры, могут быть запрещены исходными условиями. В этом случае функция должна использовать только проверку с помощью отладочных подтверждений. Если же речь идёт о полностью определённом поведении, то функции следует предписанным образом сообщать об ошибке. Я стараюсь по умолчанию следовать сценарию с неопределённым поведением и определяю для функции проверку параметров лишь тогда, когда это слишком трудно сделать на стороне вызывающего.
Заключение
Если вы создаёте библиотеки, то иногда приходится обеспечивать максимальную гибкость использования. Под этим подразумевается и разнообразие средств обработки ошибок: иногда требуются коды возврата, иногда — исключения.
Одна из возможных стратегий — улаживание этих противоречий с помощью обработчика исключений. Просто удостоверьтесь, что когда нужно, то вызывается callback, а не бросается исключение. Это замена для критических ошибок, которая в любом случае будет журналироваться перед прерыванием работы программы. Как таковой этот способ не универсален, вы не можете переключаться в одной программе между двумя версиями. Это лишь обходное решение при отключённой поддержке исключений.
Более гибкий подход — просто предоставить две перегрузки, одну с исключениями, а вторую без. Это даст пользователям максимальную свободу, они смогут выбирать ту версию, что лучше подходит в их ситуации. Недостаток этого подхода: вам придётся больше потрудиться при создании библиотеки.
Предоставить две перегрузки
Если недостаточно обработчика исключений, то нужно предоставить две перегрузки. Одна использует код возврата, а вторая бросает исключение.
Если рассматриваемая функция не имеет возвращаемого значения, то можете её использовать для кода ошибки. В противном случае вам придётся возвращать недопустимое значение для сигнализирования об ошибке — как nullptr
в вышеприведённом примере, — а также установить выходной параметр для кода ошибки, если хотите предоставить вызывающему дополнительную информацию.
Пожалуйста
, не используйте глобальную переменную errno
или что-то типа GetLastError()
!
Если возвращаемое значение не содержит недопустимое значение для обозначения сбоя, то по мере возможности используйте std::optional
или что-то похожее.
Перегрузка исключения (exception overload) может — и должна — быть реализована в рамках версии с кодом ошибки, как это показано выше. Если компилируете без исключений, сотрите перегрузку с помощью условного компилирования.
