Pull to refresh

Обработка ошибок и C++

Reading time8 min
Views28K

О чём тут не будет: напоминания базовых конструкций языка и основных моментов о том, как с ними работать; подробного разбора, как работают исключения (писали тут и тут); как грамотно спроектировать ваш класс/программу, чтобы не наломать дров в будущем с гарантией исключений (разве что совсем чуть-чуть, хотя я сам и не очень-то тук-тук).

О чём будет: разные способы обработки ошибок в C++, несколько советов от сообщества и немного заметок о различных жизненных (и не очень) ситуациях.

Текущее состояние дел

Перед тем, как посмотреть, что же есть в C++, давайте вспомним, как с ошибками жили C-программисты. Тут есть несколько опций:

  • возвращать код ошибки. Например заранее определить enum с возможными кодами ошибок:

enum err { OK = 0, UNEXPECTED };
err func(int x, int** result);
  • использовать thread-local значения вроде errno (для windows GetLastError):

  • передавать отдельную переменную для ошибки:

int* func(int x, err* errcode);
  • использовать setjmp/longjmp. В C++ стоит об этом категорически забыть (деструкторы и всё такое).

Почему этого недостаточно? Код возврата/параметр очень легко проигнорировать. Как часто вы проверяли, что вернули 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) {}
}

Но это тоже говнокод ¯\_(ツ)_/¯.

Какие-то рекомендации

Набросы из личного опыта и советов из интернетов, которые, к сожалению, получилось прочувствовать на себе:

  1. Исключения задумывались в мире, где существуют деструкторы, а значит и RAII. Используйте эту идиому максимально, если речь идёт об освобождении ресурсов.

    Если для ситуации RAII подходит недостаточно (нужно совершить не очистку ресурсов, а просто набор действий), сообразите что-то вроде gsl::finally.

  2. Используйте исключения, если в конструкторе объекта становится понятно, что объект создать невозможно (раз, два). Тут так-то других вариантов особо и нет: возвращаемое значение у конструкторов не предусмотрено. Можно конечно завести условный метод IsValid и обмазаться конструкциями с if, но имхо не оч удобно.

  3. Можно использовать исключения для проверки пред-/постусловий.

  4. В силу непредсказуемости flow выполнения вашего кода из-за исключений, можно с ними знатные приколы мутить. Встречались кейсы, когда исключения использовались для выхода из глубокой рекурсии, нескольких циклов сразу или, внезапно, даже возврата значения из функции. Не делайте так. Исключения они на то и исключения, чтобы детектить ошибки. Exceptions are for exceptional.

  5. Но не переусердствуйте с ловлей исключений. Хорошо, когда вы ожидаете какую-то конкретную ошибку и ловите именно её. Думаю, вы тоже видели код с конструкциями вида catch (...) {}, потому что “ну там какие-то исключения вылетают, а падать не хочется”. Разберитесь с этим и контролируйте (может у вас есть действительно хорошие примеры, где это наилучшее решение; тогда расскажите в комментариях).

  6. Если не можете обработать исключение, делайте аборт (std::abort/std::terminate/std::exit/std::quick_exit).

  7. Старайтесь ловить исключения так, чтобы они копировались минимальное количество раз (с помощью ссылок/указателей/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.

Tags:
Hubs:
Total votes 27: ↑26 and ↓1+25
Comments20

Articles