1. Почему важен выбор типа исключения
Когда новичок впервые узнаёт про throw, возникает соблазн: «Буду бросать строку "Error" и жить спокойно». Проблема в том, что через пару недель вы сами же будете читать свой код и думать: «Это ошибка формата? Выход за границы? Внешняя проблема? Нарушение контракта?» Тип исключения — это как ярлык на коробке.
Правильно подобранный стандартный тип исключения решает сразу две задачи: во‑первых, он позволяет обработчику (коду в catch) выбрать корректную реакцию, не пытаясь парсить текст сообщения. Во‑вторых, он дисциплинирует автора функции: если вы бросаете std::invalid_argument, значит, вы прямо признаёте, что входные данные не соответствуют контракту, и вы не собираетесь «угадывать, что имел в виду пользователь».
Чтобы не превращать программу в театр абсурда, будем придерживаться простой идеи: тип исключения должен отражать категорию проблемы, а сообщение — давать контекст. Это намного полезнее, чем "Runtime error" без деталей.
Небольшая ремарка: стандарт C++ подробно описывает механику обработки исключений (разделы спецификации про обработку исключений вроде [except.handle]).
Где живут эти исключения и как их подключать
Когда вы видите std::runtime_error, std::invalid_argument или std::out_of_range, почти всегда правильный заголовок — это <stdexcept>. Иногда новичок пытается подключить <exception> и удивляется, что компилятор ругается. Это нормально: <exception> даёт базовые вещи вроде std::exception, а «готовые типы» с сообщением лежат в <stdexcept>.
Именно эти классы хороши тем, что у них уже есть «карман» для текста: вы передаёте строку в конструктор, а в catch печатаете e.what(). Важно не путать это с «печатать ошибку в месте возникновения»: как правило, низкоуровневая функция не должна решать, как именно сообщать пользователю. Она должна сообщить “что случилось” через исключение, а решение о реакции — оставить верхнему уровню (часто main).
Мини‑пример “правильного подключения”:
#include <iostream>
#include <stdexcept>
int main() {
try {
throw std::runtime_error("something went wrong");
} catch (const std::exception& e) {
std::cout << e.what() << '\n'; // something went wrong
}
}
2. std::runtime_error: «операция сорвалась во время выполнения»
std::runtime_error звучит как «ну всё, что угодно», и частично это правда: это довольно универсальная категория для проблем, которые возникли из-за условий выполнения, а не из-за того, что программист нарушил контракт функции. По смыслу это ближе к «мир оказался не таким, как мы рассчитывали» — и операция не может корректно завершиться.
Представьте бытовую аналогию: вы пришли в магазин с корректным списком покупок (аргументы верные), но магазин внезапно закрыт (внешнее условие). Это не std::invalid_argument, потому что вы сделали всё правильно; это скорее std::runtime_error, потому что действие выполнить нельзя.
В реальных программах std::runtime_error часто используют для ошибок вроде “не удалось выполнить операцию”, “не получилось открыть ресурс”, “не удалось прочитать данные”, “состояние системы не позволяет продолжить”. Мы пока сознательно не углубляемся в файлы и системные коды ошибок, но сам смысл runtime_error вам уже нужен.
Простой пример: «операция невозможна прямо сейчас»
Сделаем маленькую функцию, которая по контракту должна «выполнить действие», но иногда не может.
#include <stdexcept>
#include <string>
void do_important_thing(bool ok) {
if (!ok) throw std::runtime_error("operation failed: service unavailable");
}
И обработка:
#include <exception>
#include <iostream>
int main() {
try {
do_important_thing(false);
} catch (const std::exception& e) {
std::cout << "Error: " << e.what() << '\n';
// Error: operation failed: service unavailable
}
}
Обратите внимание: в сообщении есть и «что делали», и «почему не вышло». Это маленькая привычка, которая потом экономит часы отладки.
3. std::invalid_argument: «аргументы не соответствуют контракту»
std::invalid_argument — это ваш способ сказать: «Я не могу продолжать, потому что входные данные не имеют смысла для этой операции». Здесь важный нюанс: речь не обязательно о “плохом пользователе”. Часто “плохие” аргументы появляются из-за ошибки в логике программы, потому что одна функция передала другой неверные данные.
Это исключение особенно полезно, когда вы пишете функции, которые должны защищать свои инварианты. Например, функция set_age(int age) может требовать age >= 0. Если туда пришло -5, то продолжать обычно нельзя: состояние объекта станет бессмысленным. И вот тут std::invalid_argument — честный выбор.
Пример: проверка аргумента
#include <stdexcept>
int days_in_week(int weekIndex) {
if (weekIndex < 0) throw std::invalid_argument("weekIndex must be >= 0");
return 7;
}
Почему это std::invalid_argument, а не std::runtime_error? Потому что проблема в аргументе: функция не обязана угадывать, что вы имели в виду, и не обязана “чинить” входные данные.
Очень жизненный источник invalid_argument: std::stoi
Если вы уже игрались со std::stoi, то могли увидеть два классических сценария: строка не похожа на число, или число слишком большое. Первый сценарий обычно связан именно с std::invalid_argument.
Мы не будем сейчас погружаться в детали stoi и исключений стандартной функции как в спецификацию, но логика для нас простая: если строка “не число”, то это “невалидный аргумент для преобразования”.
Набросок функции “строка → int”, которая выдаёт понятную ошибку:
#include <stdexcept>
#include <string>
int parse_int(const std::string& s) {
if (s.empty()) throw std::invalid_argument("empty string is not a number");
return std::stoi(s); // может бросить исключение
}
Здесь мы уже добавили свой смысловой кусок: пустая строка — точно не число, и это понятнее, чем «что-то там внутри stoi».
4. std::out_of_range: «вышли за допустимые границы»
std::out_of_range — это исключение про границы: индексы, диапазоны, лимиты. Его очень приятно использовать там, где ошибка имеет чёткую форму: «значение существует, но оно вне допустимого диапазона». Это не обязательно “аргумент плохой по смыслу”, как в std::invalid_argument. Он может быть вполне “числом”, но слишком большим, или индексом, который не помещается в коллекцию.
Самое классическое место встречи — проверяемый доступ к контейнерам. Вы уже знаете, что у std::vector есть operator[] (без проверок) и at() (с проверкой). Если индекс неверный — at() обычно сообщает об этом через std::out_of_range.
Пример: vector.at() и понятная авария
#include <iostream>
#include <stdexcept>
#include <vector>
int main() {
std::vector<int> v{10, 20, 30};
try {
std::cout << v.at(5) << '\n';
} catch (const std::out_of_range& e) {
std::cout << "out_of_range: " << e.what() << '\n';
}
}
Этот пример важен психологически: at() — это не «медленно и грустно», а «честно и безопасно, когда вы не уверены в индексах».
Второй частый источник out_of_range: слишком большое число
Даже если строка выглядит как число, оно может не помещаться в int. В таком случае ошибка не в том, что это “не число”, а в том, что число слишком большое для выбранного типа. По смыслу это чистый std::out_of_range: мы вышли за пределы диапазона int.
5. Как выбирать и как ловить эти исключения
Быстрая шпаргалка выбора
Сейчас важно собрать в голове простую “карту”. Вам не нужно становиться философом исключений. Вам нужна практичная шпаргалка, которая работает в 95% случаев.
Ниже — таблица, которая помогает быстро принять решение:
| Тип исключения | Про что оно | Типичная формулировка в голове | Примеры ситуаций |
|---|---|---|---|
|
аргумент бессмысленен для операции | “так нельзя вызывать эту функцию” | пустая строка вместо числа, отрицательный возраст |
|
значение/индекс вне диапазона | “значение слишком большое/маленькое для допустимых границ” | v.at(i) при неверном i, число не помещается в int |
|
операция сорвалась из-за условий выполнения | “я всё сделал правильно, но мир не дал выполнить” | внешний ресурс недоступен, операция не может завершиться |
Если хочется ещё чуть более “технически”, то можно помнить, что исключения “про контракт” (вроде std::invalid_argument и std::out_of_range) хорошо подходят для сигналов о нарушении предусловий, а std::runtime_error — для «проблем выполнения».
Мини‑иерархия исключений и порядок catch
Когда вы пишете несколько catch, важно помнить правило: выбирается первый подходящий обработчик. Поэтому обычно ставят более конкретные типы выше, а более общие — ниже. Это связано с наследованием: многие исключения являются потомками std::exception, и если вы поймаете std::exception слишком рано — до конкретных типов дело не дойдёт.
Давайте изобразим упрощённо:
flowchart TD
A[std::exception] --> B[std::runtime_error]
A --> C[std::logic_error]
C --> D[std::invalid_argument]
C --> E[std::out_of_range]
Сам факт того, что стандарт описывает единый механизм перехвата исключений и обработчики по типам, является частью общей модели обработки исключений.
6. Мини‑приложение: “Список чисел” с понятными ошибками
Чтобы эта тема не осталась «теорией про страшные слова», давайте продолжим линию “одно приложение, которое развивается”. Мы сделаем мини‑CLI, который хранит числа в std::vector<int> и принимает команды строкой.
Команды будут такие: добавить число, показать число по индексу, посчитать сумму. А главное — в трёх местах мы получим три разные категории ошибок и научимся выражать их разными исключениями.
Данные приложения и “скелет” команд
Начнём с самого простого: у нас есть std::vector<int> data; и цикл чтения строк. Парсинг сделаем через std::stringstream.
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
int main() {
std::vector<int> data;
std::string line;
while (std::getline(std::cin, line)) {
std::cout << "cmd> " << line << '\n'; // echo для наглядности
}
}
Пока это “пустой терминал”, но уже есть каркас, куда мы будем вставлять обработку ошибок.
Команда push: ловим invalid_argument и out_of_range от парсинга
Сделаем функцию parse_int_or_throw, которая превращает строку в int, а если нельзя — бросает понятное исключение. Здесь удобно показать оба случая: “это не число” (std::invalid_argument) и “не помещается” (std::out_of_range).
#include <stdexcept>
#include <string>
int parse_int_or_throw(const std::string& s) {
if (s.empty()) throw std::invalid_argument("number is missing");
return std::stoi(s); // может бросить invalid_argument/out_of_range
}
Теперь обработчик команды:
#include <sstream>
#include <string>
#include <vector>
void cmd_push(std::vector<int>& data, const std::string& arg) {
int x = parse_int_or_throw(arg);
data.push_back(x);
}
Обратите внимание: мы пока не ловим исключения здесь. Мы даём им подняться выше — туда, где у нас “точка общения с пользователем”.
Команда get: выбрасываем out_of_range за неверный индекс
Здесь у нас прямой кандидат: индекс. Значит, по смыслу — std::out_of_range. Мы специально используем at(), чтобы программа не молча портила память, а честно сказала “индекс неверный”.
#include <stdexcept>
#include <vector>
int cmd_get(const std::vector<int>& data, int index) {
return data.at(index); // при плохом индексе бросит out_of_range
}
Где пригодится runtime_error в нашем мини‑CLI
В нашем примере std::runtime_error удобно использовать для “операция невозможна из-за состояния”. Например, команда sum может требовать, чтобы список был непустым.
#include <numeric>
#include <stdexcept>
#include <vector>
int cmd_sum(const std::vector<int>& data) {
if (data.empty()) throw std::runtime_error("sum failed: list is empty");
return std::accumulate(data.begin(), data.end(), 0);
}
Это не std::invalid_argument, потому что аргументы функции корректны (вектор существует), но операция по правилам нашего мини‑приложения “не имеет смысла” в текущем состоянии.
Главный try/catch в main: порядок обработчиков
Теперь соберём всё вместе: парсим команду, вызываем нужную функцию, а ошибки ловим в одном месте. Тут как раз важно показать порядок: сначала std::out_of_range, потом std::invalid_argument, потом общий std::exception. Так сообщения будут более конкретными.
#include <exception>
#include <iostream>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>
int main() {
std::vector<int> data;
std::string line;
while (std::getline(std::cin, line)) {
try {
std::stringstream ss(line);
std::string cmd, arg;
ss >> cmd >> arg;
if (cmd == "push") cmd_push(data, arg);
else if (cmd == "get") std::cout << cmd_get(data, parse_int_or_throw(arg)) << '\n';
else if (cmd == "sum") std::cout << cmd_sum(data) << '\n';
else throw std::invalid_argument("unknown command: " + cmd);
} catch (const std::out_of_range& e) {
std::cout << "Range error: " << e.what() << '\n';
} catch (const std::invalid_argument& e) {
std::cout << "Bad input: " << e.what() << '\n';
} catch (const std::exception& e) {
std::cout << "Error: " << e.what() << '\n';
}
}
}
Здесь важно увидеть несколько вещей одновременно. std::invalid_argument мы используем как для “не то число”, так и для “не та команда” — потому что это всё ошибки входных данных. std::out_of_range отдельно ловим, потому что это частый и понятный класс ошибок. А std::exception остаётся “сеткой безопасности”.
7. Типичные ошибки
Ошибка №1: использовать один std::runtime_error “на всё подряд”.
Так действительно можно быстро “заставить компилироваться”, но вы теряете смысл типов. Через месяц вы будете ловить runtime_error и не понимать, это плохой ввод, выход за границы или реально внешняя проблема выполнения. Минимальная дисциплина — отделять invalid_argument (плохие входные данные) и out_of_range (плохие границы) от “прочих” проблем выполнения.
Ошибка №2: ставить catch (const std::exception&) выше, чем catch (const std::out_of_range&).
Это ломает идею конкретной обработки: общий обработчик перехватит всё первым, и до “узких” catch программа не дойдёт. Симптом обычно такой: вы уверены, что ловите out_of_range, но всегда попадаете в общий std::exception. Лечится просто: порядок catch должен идти от конкретного к общему.
Ошибка №3: ловить исключения по значению (catch (std::exception e)).
Это выглядит безобидно, но может привести к лишним копиям и к “срезке” (потере информации о реальном типе исключения), если вы ловите базовый тип по значению. Базовая привычка — catch (const std::exception& e) и так же для производных.
Ошибка №4: делать пустой catch и “продолжать как ни в чём не бывало”.
Пустой обработчик — это способ спрятать проблему, а не решить её. Иногда его используют как “костыль”, чтобы программа не падала, но это часто хуже падения: состояние приложения может стать неконсистентным, а пользователь получит странные результаты. Минимум, что должен делать catch, — сообщать об ошибке или явно прекращать текущую операцию.
Ошибка №5: смешивать “сообщение пользователю” и “сообщение разработчику” в одном тексте.
Если вы пишете throw std::invalid_argument("Bad input at parse_int_or_throw(): token='...' line=..."), это может быть полезно на ранних этапах, но со временем сообщения превращаются в шум. Лучше держать сообщение коротким и содержательным: что именно не так и какой ключевой параметр виноват. Когда появятся логи и уровни диагностики, тогда можно будет усложнять, но не сегодня.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ