Зачем нужны исключения
На предыдущей странице вы использовали enum class, чтобы дать состояниям ошибок осмысленные имена. Это отлично подходит для исходов, которые функция предвидит и которые вызывающий код должен проверять. Но некоторые сбои иные: функция глубоко в стеке вызовов обнаруживает, что файл не открывается или что аргумент не имеет смысла, и она понятия не имеет, что программа должна с этим делать. Возврат кода ошибки работает, только если каждый вызывающий в цепочке не забудет проверить его и передать наверх. Пропустите хоть одну проверку — и программа поплывёт дальше с мусором.
Исключения решают эту проблему. Когда что-то идёт не так, вы выбрасываете объект с помощью throw. Выполнение немедленно останавливается, стек разворачивается (для каждого локального объекта между выбросом и обработчиком вызывается деструктор), и управление переходит к ближайшему подходящему catch. Необработанное исключение нельзя тихо проигнорировать: если его ничто не перехватит, программа вызовет std::terminate и аварийно завершится.
Эта страница посвящена стороне выброса — самим объектам ошибок. Следующая страница подробно разбирает механику try/catch.
Выброс и сообщение what()
Технически вы можете выбросить (throw) любое значение —throw 42; или throw "oops"; допустимы— но не делайте этого. Соглашение, которому следуют все, — выбрасывать объект, производный от std::exception. Этот базовый класс объявляет единственный виртуальный метод what(), возвращающий описание проблемы в виде const char*. Соблюдение соглашения означает, что один-единственный catch (const std::exception& e) может обработать что угодно.
Заголовок <stdexcept> даёт вам готовые типы, конструктор которых принимает сообщение:
Обратите внимание, что what() возвращает ровно ту строку, с которой вы сконструировали исключение. Заметьте также, что мы перехватили его по const exception&, хотя выбросили runtime_error: это работает, потому что runtime_error является std::exception (отношение, знакомое вам по странице о наследовании).
Стандартная иерархия исключений
Прежде чем писать собственный тип исключения, проверьте, нет ли уже подходящего в стандартной библиотеке. Все они наследуются от std::exception и в <stdexcept> делятся на два семейства:
logic_error— ошибка в логике программы, которую в принципе можно было бы выявить ещё до запуска. К подтипам относятсяinvalid_argument,out_of_range,domain_errorиlength_error.runtime_error— сбой, проявляющийся только во время выполнения и сам по себе не являющийся ошибкой программирования. К подтипам относятсяrange_error,overflow_errorиunderflow_error.
Многие функции библиотеки выбрасывают их за вас. Например, std::vector::at() проверяет границы и выбрасывает out_of_range вместо того, чтобы позволить вам прочитать за концом:
Этот at() — безопасный аналог v[9]. Простой operator[] не проверяет границы: чтение v[9] здесь — это неопределённое поведение, а не исключение. Выбор at() — это способ превратить тихое повреждение данных в перехватываемую ошибку.
Выбирайте тип, который описывает ошибку: invalid_argument, когда вызывающий передаёт нечто бессмысленное, out_of_range для проблем с индексом/ключом, runtime_error для случаев «внешний мир меня подвёл».
Создание собственного типа исключения
Когда ни один стандартный тип не подходит — вы хотите прикрепить дополнительные данные или перехватить (catch) именно вашу ошибку и ничего больше — определите класс, наследующий от std::exception (или от одного из его подтипов), и переопределите what(). Наследование от std::runtime_error — самый простой путь, потому что он уже хранит сообщение и реализует what() за вас:
Поскольку NetworkError несёт код состояния, обработчик может на него реагировать: повторить попытку при 5xx, сдаться при 4xx. Голая строка с ошибкой так не смогла бы. Пользовательский тип также позволяет catch (const NetworkError&) перехватывать только сетевые проблемы, оставляя всё остальное более общему обработчику ниже.
Если вы когда-нибудь будете наследоваться напрямую от std::exception (а не от runtime_error), не забудьте переопределить what() самостоятельно и пометить его noexcept, чтобы он совпадал с сигнатурой базового класса:
class ParseError : public std::exception {
public:
const char* what() const noexcept override {
return "failed to parse input";
}
};
Выбрасывайте по значению, перехватывайте по ссылке
Это самое важное правило исключений в C++, и именно его новички нарушают. Выбрасывайте объекты по значению и перехватывайте их по const-ссылке.
throw runtime_error("oops"); // по значению - правильно
catch (const runtime_error& e) { ... } // по const-ссылке - правильно
Перехват по значению вместо этого —catch (std::exception e)— копирует исключение в объект базового класса и срезает производную часть. После срезки e.what() вызывает реализацию базового класса, а не вашу переопределённую, поэтому ваше тщательно составленное сообщение исчезает:
try {
throw NetworkError(503, "service unavailable");
} catch (std::exception e) { // по значению - срезка объекта!
std::cout << e.what(); // обобщённое сообщение, status() пропал
}
Ссылка (&) сохраняет настоящий динамический тип, поэтому виртуальный what() диспетчеризуется корректно, и вы по-прежнему можете обращаться к производным членам. Добавьте const, потому что вы только читаете исключение, а не изменяете его. Никогда не выбрасывайте указатель (throw new runtime_error(...)): перехватившему пришлось бы вызвать для него delete — но на каком пути выполнения? Это ровно та утечка, которую исключения и призваны предотвращать.
Далее: try-catch
Теперь вы умеете создавать и выбрасывать (throw) корректные исключения и выбирать подходящий стандартный тип для каждого сбоя. Вторая половина истории — сторона перехвата. Следующая страница полностью разбирает try/catch: упорядочивание нескольких блоков catch от самого конкретного к самому общему, перехватывающий всё catch (...), повторный выброс с помощью голого throw; и то, как RAII (вспомните умные указатели) гарантирует освобождение ваших ресурсов по мере разворачивания стека.
Часто задаваемые вопросы
Что такое исключение в C++?
Исключение — это объект, сигнализирующий об ошибке, которую текущая функция не может обработать сама. Вы выбрасываете его с помощью throw, стек разворачивается (по пути уничтожая локальные объекты), и управление передаётся подходящему блоку catch выше по стеку. Это отделяет код, который обнаруживает проблему, от кода, который решает, что с ней делать.
В чём разница между throw и return для ошибок?
Значение return должен проверить вызывающий код, а про это легко забыть — и программа просто продолжает работу с испорченными данными. Выброшенное исключение нельзя проигнорировать: если его никто не перехватит, программа завершится. Исключения нужны для настоящих сбоев (файл не открывается, ввод некорректен); возвращаемые значения по-прежнему уместны для обычных результатов, включая ожидаемые случаи «не найдено».
Что делает метод what() в исключениях C++?
Каждый класс, производный от std::exception, предоставляет виртуальный метод what(), возвращающий const char* с описанием ошибки. Когда вы перехватываете исключение, вызов e.what() даёт вам понятное человеку сообщение, которое можно записать в лог или вывести. Стандартные типы исключений формируют его из строки, переданной в их конструктор.