JavaRush /Курсы /C++ SELF /Погружаемся в try/catch/throw

Погружаемся в try/catch/throw

C++ SELF
53 уровень , 0 лекция
Открыта

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;).

1
Задача
C++ SELF, 53 уровень, 0 лекция
Недоступна
Делёж бюджета
Делёж бюджета
1
Задача
C++ SELF, 53 уровень, 0 лекция
Недоступна
Ловушка режимов
Ловушка режимов
1
Задача
C++ SELF, 53 уровень, 0 лекция
Недоступна
Трассировка уровней
Трассировка уровней
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ