JavaRush /Курсы /C++ SELF /Стандартные исключения <stdexcept>

Стандартные исключения <stdexcept>

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

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% случаев.

Ниже — таблица, которая помогает быстро принять решение:

Тип исключения Про что оно Типичная формулировка в голове Примеры ситуаций
std::invalid_argument
аргумент бессмысленен для операции “так нельзя вызывать эту функцию” пустая строка вместо числа, отрицательный возраст
std::out_of_range
значение/индекс вне диапазона “значение слишком большое/маленькое для допустимых границ” v.at(i) при неверном i, число не помещается в int
std::runtime_error
операция сорвалась из-за условий выполнения “я всё сделал правильно, но мир не дал выполнить” внешний ресурс недоступен, операция не может завершиться

Если хочется ещё чуть более “технически”, то можно помнить, что исключения “про контракт” (вроде 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=..."), это может быть полезно на ранних этапах, но со временем сообщения превращаются в шум. Лучше держать сообщение коротким и содержательным: что именно не так и какой ключевой параметр виноват. Когда появятся логи и уровни диагностики, тогда можно будет усложнять, но не сегодня.

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