JavaRush /Курсы /C++ SELF /std::exception и what(): базовая практика

std::exception и what(): базовая практика

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

1. Зачем нужен std::exception, если можно бросать что угодно

Если смотреть на C++ строго формально, то бросать можно почти что угодно: число, строку, структуру, кота (если кот копируемый). Проблема в том, что ловить “что угодно” неудобно: вы не знаете, как это печатать, как классифицировать и как писать общий обработчик.

std::exception решает именно эту бытовую боль: он даёт единый минимальный интерфейс “я ошибка, вот мой текст”.

std::exception — это базовый класс стандартных исключений. Главное, что он приносит в наш код — метод:

const char* what() const noexcept;

То есть исключение (если оно совместимо со std::exception) умеет сказать вам текстовое описание проблемы. Не идеальное, не всегда подробное, но хотя бы что-то, а не “ой, всё”.

Важно и практично следующее: подавляющее большинство исключений стандартной библиотеки наследуются от std::exception, поэтому “универсальный обработчик” обычно выглядит как catch (const std::exception& e).

Небольшая схема, чтобы закрепить роль std::exception:

flowchart TD
    A["throw (какой-то объект ошибки)"] --> B{Ищем подходящий catch}
    B -->|"catch (const SomeType&)"| C[Специфичная обработка]
    B -->|"catch (const std::exception& e)"| D["Общий обработчик
можно распечатать e.what()"] B -->|"catch (...)"| E["План Б
тип неизвестен, what() нет"]

2. Базовые правила: как ловить исключения

Ловим std::exception по const&

Когда исключение уже вылетело из глубины ваших функций и прилетело в catch, вы обычно хотите две вещи: во‑первых, не потерять информацию об ошибке, во‑вторых, не устроить лишние копирования.

Поэтому “золотая формула” для общего случая звучит так: ловим по const ссылке. Это общепринятая практика: полиморфные исключения ловят по ссылке, чтобы не терять тип и поведение.

Вот минимальный скелет “поймали и распечатали”:

#include <exception>
#include <iostream>
#include <stdexcept>

int main() {
    try {
        throw std::runtime_error("something went wrong");
    } catch (const std::exception& e) {
        std::cout << "error: " << e.what() << '\n'; // error: something went wrong
    }
}

Обратите внимание: мы пока не обсуждаем “какой тип исключения выбирать” — это следующая лекция. Здесь нам важен именно общий интерфейс: если исключение — наследник std::exception, то у него есть what().

Почему const? Потому что в обработчике вы обычно не должны “чинить” исключение. Вы должны его прочитать, вывести сообщение, возможно — завершить операцию. А & (ссылка) — чтобы не делать копию и не потерять динамический тип (об этом очень скоро).

Порядок catch: где место std::exception, а где — catch (...)

Когда в коде несколько обработчиков, компилятор выбирает первый подходящий по типу. Поэтому порядок catch — это не “как мне красиво”, а часть логики.

На практике std::exception — это “широкая сеть”: она ловит очень многое. Если поставить её первой, все более специфичные обработчики ниже станут недостижимыми.

Здесь важно удержать в голове правильную лестницу:

  1. Сначала — самые конкретные типы (если вы их обрабатываете по‑особенному).
  2. Потом — общий обработчик const std::exception&.
  3. И только потом — catch (...), как последний рубеж, когда тип вообще неизвестен.

Мини‑пример, чисто чтобы увидеть структуру (без углубления в типы — оно будет дальше по плану):

#include <exception>
#include <iostream>
#include <stdexcept>

int main() {
    try {
        throw std::runtime_error("boom");
    } catch (const std::runtime_error& e) {
        std::cout << "runtime_error: " << e.what() << '\n'; // runtime_error: boom
    } catch (const std::exception& e) {
        std::cout << "std::exception: " << e.what() << '\n';
    } catch (...) {
        std::cout << "unknown error\n";
    }
}

Обратите внимание: catch (...) не даёт вам what(), потому что там нет объекта исключения определённого типа. Это “ловим всё, но ничего не знаем”. Поэтому catch (...) — это не замена нормальной диагностики, а страховочная сетка, чтобы программа не падала молча.

3. what(): что возвращает и как печатать

Когда вы впервые видите what(), мозг новичка часто ожидает std::string: мол, “ну текст же”. Но исторически (и по соображениям совместимости) what() возвращает const char*. Это значит: у вас в руках указатель на C‑строку, то есть строку, заканчивающуюся '\0'.

Самый простой (и правильный) сценарий — просто вывести what() прямо в catch:

#include <exception>
#include <iostream>
#include <stdexcept>

int main() {
    try {
        throw std::runtime_error("division by zero");
    } catch (const std::exception& e) {
        std::cout << e.what() << '\n'; // division by zero
    }
}

Почему это удобно? Потому что вы получаете хотя бы одну строку в логах/консоли вместо загадочного “программа завершилась”.

Немного про время жизни

Здесь есть тонкость: what() возвращает указатель на память, которая принадлежит объекту исключения. Пока вы внутри catch, объект исключения живой, и строка обычно валидна. Но как только вы вышли из catch, хранить этот указатель “на потом” — плохая идея.

Плохой пример (делать так не нужно):

#include <exception>
#include <iostream>
#include <stdexcept>

int main() {
    const char* msg = nullptr;

    try {
        throw std::runtime_error("bad input");
    } catch (const std::exception& e) {
        msg = e.what();
    }

    std::cout << msg << '\n'; // ⚠ может быть уже невалидно (висячий указатель)
}

Хороший, “человеческий” вариант — скопировать текст в std::string, если вам нужно использовать сообщение после catch:

#include <exception>
#include <iostream>
#include <stdexcept>
#include <string>

int main() {
    std::string msg;

    try {
        throw std::runtime_error("bad input");
    } catch (const std::exception& e) {
        msg = e.what(); // копируем текст
    }

    std::cout << msg << '\n'; // bad input
}

Запомните простую метафору: what() — это “записка”, приклеенная к объекту исключения. Пока объект у вас в руках — записку можно читать. Если хотите унести записку домой — перепишите в свой блокнот (std::string).

4. Почему нельзя ловить std::exception по значению

Сейчас будет момент, который ломает мозг ровно один раз — и потом экономит вам часы отладки.

Исключения часто полиморфные: вы бросаете объект производного типа, например “какой-то конкретный runtime error”, а ловите — по базовому типу std::exception. Это нормально, это задумано.

Но если вы ловите по значению:

catch (std::exception e) { ... }

то происходит срезание объекта (object slicing): производная часть “отрезается”, и в переменной e остаётся только базовая часть std::exception. А значит, вы можете потерять “правильную” реализацию what() и получить максимально бесполезный текст.

Давайте сравним лоб в лоб.

Плохой вариант — ловим по значению:

#include <exception>
#include <iostream>
#include <stdexcept>

int main() {
    try {
        throw std::runtime_error("file not found");
    } catch (std::exception e) { // ⚠ ловим по значению
        std::cout << e.what() << '\n'; // часто будет что-то вроде "std::exception"
    }
}

Хороший вариант — ловим по ссылке:

#include <exception>
#include <iostream>
#include <stdexcept>

int main() {
    try {
        throw std::runtime_error("file not found");
    } catch (const std::exception& e) { // ✅ по ссылке
        std::cout << e.what() << '\n'; // file not found
    }
}

Вот почему правило “ловим по const&” — не эстетика и не религия, а способ не потерять сообщение об ошибке.

Можно думать так: ловля по значению — это как если бы вам принесли коробку с надписью “Хрупкое стекло”, а вы перепаковали её в коробку “Просто коробка” и удивились, что на ней нет инструкции “не ронять”.

5. Практический пример: мини‑калькулятор и диагностика через what()

Сейчас соберём небольшой консольный “калькулятор для бедных, но гордых”. Он будет читать строки вида 12 / 3 и печатать результат. А если что-то пошло не так — вместо молчаливой смерти он выведет сообщение ошибки через what().

Шаг 1: функция деления с проверкой на ноль

Начнём с маленькой функции. Если делить на ноль, мы не хотим возвращать “магическое значение” типа 0 и делать вид, что всё нормально. Мы хотим сигнализировать ошибку.

#include <stdexcept>

double safe_div(double a, double b) {
    if (b == 0.0) {
        throw std::runtime_error("division by zero");
    }
    return a / b;
}

Да, здесь используется std::runtime_error. Сегодня нам важно не “почему именно он”, а то, что его можно поймать как std::exception и прочитать what().

Шаг 2: вычисление выражения по оператору

Добавим маленький диспетчер операций. Ничего умного, просто четыре действия.

#include <stdexcept>

double eval(double a, char op, double b) {
    if (op == '+') return a + b;
    if (op == '-') return a - b;
    if (op == '*') return a * b;
    if (op == '/') return safe_div(a, b);
    throw std::runtime_error("unknown operator");
}

Обратите внимание на стиль: если оператор неизвестен, мы не пытаемся “как‑то угадать”. Мы прекращаем операцию через исключение.

Шаг 3: парсинг строки через std::stringstream

Теперь читаем строку и достаём a op b. Мы делали потоковый парсинг раньше, поэтому тут ничего магического.

#include <sstream>
#include <stdexcept>
#include <string>

struct Expr {
    double a{};
    char op{};
    double b{};
};

Expr parse_expr(const std::string& line) {
    std::stringstream ss(line);
    Expr e{};
    if (!(ss >> e.a >> e.op >> e.b)) {
        throw std::runtime_error("bad expression format");
    }
    return e;
}

Если строка пустая или кривая, мы бросаем исключение. А кто будет решать, что с этим делать? Тот, кто управляет сценарием ввода-вывода: обычно это main() или функция уровня “run”.

Шаг 4: граница обработки — try/catch в main()

Теперь соберём “тонкий” main, который умеет: читать строку, парсить, считать, печатать. И, что важно, в одном месте ловить ошибки как std::exception и печатать what().

#include <exception>
#include <iostream>
#include <string>

int main() {
    try {
        std::string line;
        std::getline(std::cin, line);

        const Expr ex = parse_expr(line);
        const double result = eval(ex.a, ex.op, ex.b);

        std::cout << result << '\n'; // например: 4
    } catch (const std::exception& e) {
        std::cout << "error: " << e.what() << '\n';
    }
}

Что мы выиграли? Если ввод был 12 / 0, программа не падает молча и не печатает “0”. Она честно скажет error: division by zero. Если ввод был hello, скажет error: bad expression format.

А главное — этот стиль масштабируется: вы можете углублять программу, добавлять функции, и всё равно иметь общий обработчик, который не превращает отладку в гадание на кофейной гуще.

Границы ответственности

Заметьте, как распределились роли. Функции parse_expr и eval не печатают ошибки и не спрашивают пользователя “а вы точно это имели в виду?”. Они просто сообщают: “я не могу сделать работу”. А main() — это тот, кто решает, что делать с этой новостью: показать сообщение и завершиться (или в более сложном приложении — попросить ввести заново).

Это хороший дизайн уже на уровне новичка: меньше каши из std::cout по всему проекту.

6. Типичные ошибки при работе с std::exception и what()

Когда вы начинаете использовать исключения в реальном коде, ошибки чаще всего не в “сложной теории”, а в мелких привычках. Многие из них выглядят безобидно, потому что программа иногда даже “работает”, но диагностика становится плохой, а поведение — странным. Сейчас разберём самые частые грабли, чтобы вы наступили на них максимум один раз (и то в учебных целях).

Ошибка №1: ловить std::exception по значению (catch (std::exception e)).
Это приводит к object slicing: вы теряете динамический тип исключения, а вместе с ним — полезное сообщение what() и иногда другие детали. В результате в консоли появляется что-то вроде “std::exception” вместо нормального текста. Правильная привычка: catch (const std::exception& e).

Ошибка №2: использовать только catch (...) и думать, что этого достаточно.
catch (...) действительно ловит “всё”, но он не даёт вам what(), потому что у вас нет типизированного объекта исключения. Итог: вы ловите ошибку, но не понимаете какую. Нормальный минимум — иметь обработчик catch (const std::exception& e) и выводить e.what(), а catch (...) оставлять как последний рубеж.

Ошибка №3: сохранять const char* из what() и использовать после выхода из catch.
Внутри catch это обычно работает, а после — может внезапно превратиться в “мусор” или привести к неопределённому поведению. Если вам нужно сообщение позже, копируйте: std::string msg = e.what();. Это простая операция, которая экономит кучу нервов.

Ошибка №4: делать пустой catch и “глотать” ошибку.
Иногда новичок пишет catch (...) {} или catch (const std::exception&) {} “чтобы не падало”, и программа продолжает жить в поломанном состоянии. Это похоже на заклеивание лампочки Check Engine чёрной изолентой: на панели стало красиво, а внутри всё так же плохо. Если вы ловите исключение, у вас должна быть стратегия: вывести сообщение, прекратить операцию, вернуть управление выше.

Ошибка №5: ставить catch (const std::exception& e) выше более конкретных обработчиков.
Если общий обработчик стоит раньше, до специфичных catch управление никогда не дойдёт. Это не только “неаккуратно”, но и может сломать вашу логику обработки ошибок. Держите порядок: конкретные типы выше, std::exception ниже, catch (...) — последним.

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