1. Исключение и throw
Когда программа работает «в идеальном мире», управление течёт по коду сверху вниз: вызвали функцию, она вернула результат, пошли дальше. Но в реальной жизни пользователь вводит «сто» вместо 100, файл не открывается, индекс вылезает за пределы, а кот ложится на клавиатуру и нажимает Enter 12 раз подряд. В таких ситуациях нам нужно уметь резко и аккуратно выйти из текущего сценария выполнения.
Исключение (exception) — это механизм, который позволяет прервать обычный путь выполнения и передать управление в заранее подготовленную точку обработки ошибки. Важно сразу настроиться на правильную мысль: исключение — это не «ещё один способ вернуть значение», а именно другой маршрут управления.
В C++ этот маршрут строится тремя ключевыми словами:
- throw — «бросить ошибку»
- try — «в этом блоке я готов ловить ошибки»
- catch — «вот как я их обрабатываю»
throw: как “бросается” исключение и что происходит с кодом после него
Когда мы пишем throw ...;, мы буквально говорим программе: «всё, дальше по этому пути смысла нет, срочно ищи обработчик». Поэтому первый шок новичка обычно звучит так: «Подождите… а строки после throw правда не выполняются?» Да, правда. Это как нажать аварийный выключатель: свет (обычный поток выполнения) погас, и включилась аварийная логика.
Мини-пример на 8 строк:
#include <iostream>
int main() {
std::cout << "before\n";
throw 42;
std::cout << "after\n"; // не выполнится
}
Если запустить такой код без catch, программа аварийно завершится, потому что исключение никто не обработал.
Теперь добавим обработчик:
#include <iostream>
int main() {
try {
std::cout << "before\n";
throw 42;
std::cout << "after\n"; // не выполнится
} catch (int x) {
std::cout << "caught: " << x << '\n'; // caught: 42
}
}
Здесь важно почувствовать механику: throw «телепортирует» управление в catch, а не делает красивый return.
2. try/catch и распространение исключения
try/catch: границы перехвата и первый подходящий обработчик
try — это не «включить режим безопасности на всю программу». Это именно граница: внутри фигурных скобок try { ... } мы говорим: «если внутри случится исключение — попробуем обработать». Если внутри исключение возникло, C++ начинает искать catch, который подходит по типу.
Общий вид:
try {
// код, где может произойти ошибка
} catch (Тип e) {
// обработка
}
А теперь ключевое правило: если catch-блоков несколько, выбирается первый подходящий. Поэтому порядок обработчиков важен: от более конкретных к более общим.
Пример с несколькими обработчиками:
#include <iostream>
#include <string>
int main() {
try {
throw std::string{"bad input"};
} catch (const std::string& s) {
std::cout << "string: " << s << '\n'; // string: bad input
} catch (...) {
std::cout << "fallback\n";
}
}
Здесь мы поймали std::string по const&. Почему так принято делать? Потому что ловить по значению (catch (std::string s)) — это лишняя копия. А ещё (чуть позже по курсу это будет прям больно) ловля по значению может «срезать» тип, если исключение полиморфное.
И ещё один важный момент: catch (...) — это «лови что угодно». Его ставят последним, иначе он перехватит всё раньше других обработчиков, и остальные catch станут бесполезными, как зонтик в пустыне.
Распространение исключения: как оно поднимается по стеку
Исключение не обязано обрабатываться там, где оно возникло. Если в функции случилась ошибка, но функция не знает, что с ней делать, она может просто “не ловить”, и исключение уйдёт выше — в ту функцию, которая её вызвала.
Это и называется распространение исключения.
Простейшая демонстрация:
#include <iostream>
#include <string>
void inner() {
throw std::string{"boom"};
}
void outer() {
inner(); // тут не ловим
}
int main() {
try {
outer();
} catch (const std::string& e) {
std::cout << "caught: " << e << '\n'; // caught: boom
}
}
Ментальная модель такая: исключение летит вверх по цепочке вызовов, пока не найдёт подходящий catch. Если не найдёт вообще (даже в main) — программа завершится аварийно.
Чтобы это стало совсем наглядно, представим маршрут:
flowchart TD
A["inner(): throw"] --> B{Есть catch в inner?}
B -- нет --> C["outer()"]
C --> D{Есть catch в outer?}
D -- нет --> E["main()"]
E --> F{Есть catch в main?}
F -- да --> G[обработка]
F -- нет --> H[аварийное завершение]
3. Раскрутка стека и повторный throw
Раскрутка стека: почему деструкторы всё равно вызываются
Когда исключение “идёт наверх”, программа вынуждена покинуть текущие области видимости: выйти из функций, блоков, уничтожить локальные переменные. Этот процесс и называется раскрутка стека (stack unwinding). В стандарте C++ это описывается именно через время жизни объектов и выход из областей видимости, а не только через «что там у деструкторов».
Главное практическое следствие: при раскрутке стека вызываются деструкторы всех уже созданных локальных объектов, в обратном порядке их создания. То есть если вы положили ресурс в объект (RAII), то при исключении ресурс освобождается автоматически.
Давайте это увидим глазами, а не верой.
#include <iostream>
#include <string>
struct Trace {
std::string name;
explicit Trace(std::string n) : name(std::move(n)) {
std::cout << "+ " << name << '\n';
}
~Trace() {
std::cout << "- " << name << '\n';
}
};
void f() {
Trace t{"f"};
throw std::string{"fail"};
}
int main() {
try {
Trace m{"main"};
f();
} catch (const std::string& e) {
std::cout << "handled: " << e << '\n';
}
}
Вывод будет примерно такой:
+ main
+ f
- f
- main
handled: fail
Обратите внимание: деструкторы отработали, хотя мы “вылетели” из f() не через return, а через исключение.
И вот тут обычно появляется второй важный вывод: деструкторы не должны бросать исключения (если это не продуманный очень особый случай). Стандартная практика исходит из того, что деструкторы не должны “вылетать” с ошибкой, если только явно не задекларировано иначе.
Где ловить исключения и как пробросить дальше через throw;
Новички часто хотят поставить try/catch везде: в каждой функции, на каждом шаге, “чтобы безопаснее”. Получается код, похожий на слоёный пирог из try, где непонятно ни логика, ни настоящая точка обработки.
Практический подход проще: ловим исключение там, где мы можем принять решение. Решение — это не обязательно “починить”. Иногда решение — это “добавить контекст и пробросить выше”. Иногда — “остановить операцию и показать сообщение пользователю”.
Чтобы пробросить исключение дальше, внутри catch есть специальная форма:
- throw; — повторно бросить то же самое исключение (не создавая новое)
Пример:
#include <iostream>
#include <string>
void parse() {
throw std::string{"bad token"};
}
void run() {
try {
parse();
} catch (const std::string& e) {
std::cout << "run saw: " << e << '\n'; // run saw: bad token
throw; // проброс того же исключения
}
}
int main() {
try {
run();
} catch (const std::string& e) {
std::cout << "main handled: " << e << '\n'; // main handled: bad token
}
}
Обратите внимание на тонкость: throw; можно писать только внутри catch. Если написать его “в чистом поле” — компилятор не поймёт, что именно вы хотите перебросить.
4. Практический пример: список задач и ошибки парсинга
Сейчас соберём маленький каркас консольного приложения и добавим в него исключения так, чтобы это было похоже на реальный код: есть “низкий уровень” (парсинг команды), есть “средний уровень” (выполнение команды), и есть “верхний уровень” (цикл main, который общается с пользователем).
Мы сделаем команды:
- add <текст> — добавить задачу
- list — показать задачи
- quit — выйти
Ошибки формата будем сигнализировать исключением (пока простым: строкой). В следующих лекциях мы научимся выбирать более правильные типы исключений, но механику удобнее понять на простом.
Модель команды
Начнём с простой структуры Command:
#include <string>
enum class CommandType {
Add,
List,
Quit
};
struct Command {
CommandType type{};
std::string payload; // текст для add
};
Парсер команды: здесь бросаем исключение
Парсер получает строку и либо возвращает Command, либо бросает исключение, если формат неверный.
#include <string>
Command parse_command(const std::string& line) {
if (line == "list") return Command{CommandType::List, ""};
if (line == "quit") return Command{CommandType::Quit, ""};
const std::string prefix = "add ";
if (line.starts_with(prefix) && line.size() > prefix.size()) {
return Command{CommandType::Add, line.substr(prefix.size())};
}
throw std::string{"unknown command: '" + line + "'"};
}
Здесь важная мысль: парсер не печатает ничего. Он только сообщает: “команда плохая”. Решение “что показать пользователю” — это задача верхнего уровня.
Выполнение команды: исключения не ловим
#include <iostream>
#include <string>
#include <vector>
void execute(const Command& cmd, std::vector<std::string>& tasks) {
if (cmd.type == CommandType::Add) {
tasks.push_back(cmd.payload);
std::cout << "added: " << cmd.payload << '\n'; // added: ...
}
if (cmd.type == CommandType::List) {
for (std::size_t i = 0; i < tasks.size(); ++i) {
std::cout << i << ") " << tasks[i] << '\n';
}
}
}
main: ловим исключение и продолжаем цикл
Вот где у нас настоящая “граница”: мы общаемся с пользователем, и если он ввёл ерунду, мы не хотим падать — мы хотим объяснить ошибку и продолжить.
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> tasks;
std::string line;
while (true) {
std::cout << "> ";
std::getline(std::cin, line);
try {
Command cmd = parse_command(line);
if (cmd.type == CommandType::Quit) break;
execute(cmd, tasks);
} catch (const std::string& e) {
std::cout << "error: " << e << '\n';
}
}
}
Теперь сценарий “пользователь ошибся” не убивает программу. Исключение летит из parse_command() в main(), там ловится и превращается в сообщение.
Исключения — не замена if
После таких примеров возникает соблазн использовать исключения как “удобный прыжок” вместо обычной логики: мол, не хочу писать if, кину исключение и поймаю где-то рядом. Технически можно, но по смыслу — это плохая привычка. Исключения в C++ предназначены для ситуаций, когда мы не можем продолжать обычный сценарий функции.
Можно представить разницу между обычным return и throw как две трассы:
flowchart LR
A[Функция] -->|обычно| B[return результат]
A -->|ошибка| C[throw исключение]
C --> D{есть catch?}
D -->|да| E[обработка]
D -->|нет| F[аварийное завершение]
return — это часть нормального маршрута. throw — аварийный выход, который ищет “спасательную дверь” (catch) выше по стеку.
5. Типичные ошибки
Ошибка №1: ожидать, что код после throw выполнится.
Иногда новичок пишет throw, а потом ещё что-то “на всякий случай”: лог, присваивание, вывод в std::cout. Это не сработает: после throw обычный поток выполнения обрывается, и управление уходит на поиск обработчика. Если вам нужно вывести что-то до броска — выводите до throw.
Ошибка №2: ловить исключение не тем типом и удивляться, что catch не срабатывает.
Если вы бросили std::string, а ловите int, обработчик не подходит. В результате исключение улетит выше. Поэтому полезная дисциплина: в голове (или в комментарии на первых порах) держать связку “что бросаем” → “что ловим”.
Ошибка №3: ловить объект по значению без причины.
catch (T e) создаёт копию. Для тяжёлых объектов это лишняя работа, а для полиморфных исключений может привести к потере реального типа (срезке). Базовый безопасный вариант — catch (const T& e).
Ошибка №4: ставить catch (...) слишком рано.
Если catch (...) стоит первым, то все остальные обработчики становятся недостижимыми по смыслу: “лови всё” перехватит любое исключение. Если вам нужен универсальный “последний рубеж”, ставьте catch (...) самым последним.
Ошибка №5: “глотать” исключение и продолжать, как будто ничего не произошло.
Пустой catch без действий выглядит как уборка мусора под ковёр: снаружи чисто, но запах останется. Минимум — выведите сообщение. Если не знаете, что делать, часто правильнее пробросить дальше (мы увидели throw;).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ