1. Два подхода к ошибкам: прыжок и значение
Когда вы пишете функцию, у вас всегда есть скрытый вопрос: «а что делать, если что-то пошло не так?». В C++ есть два больших семейства ответов. Первое — исключения, где ошибка — это отдельный путь управления потоком (условный «прыжок» из глубины кода туда, где готовы разбираться). Второе — ошибка как значение, где функция возвращает результат, который явно говорит: «успех» или «неуспех» (например, через std::optional или std::variant).
Эти инструменты не конкуренты уровня «кто сильнее», они скорее как молоток и отвёртка: оба полезные, но у них разные задачи.
Кстати, std::optional и std::variant — это не какая-то «новая мода», а стандартные инструменты библиотеки (вошли в стандартную библиотеку вместе с C++17), и именно поэтому их удобно использовать как язык для контрактов функций.
Ошибка как часть контракта: ожидаемо и неожиданно
Самая частая причина споров про исключения — люди спорят не про синтаксис, а про смысл: является ли ошибка нормальной частью работы функции или это авария. Здесь очень помогает мыслить в терминах контракта.
Если вы вызываете функцию, у вас обычно есть ожидание: «она умеет работать в таких-то условиях». Если условия нарушены, это может быть:
- ожидаемый исход — например, поиск может ничего не найти, пользователь мог ввести неправильную команду, файл мог отсутствовать (как факт жизни, а не катастрофа);
- неожиданная проблема — например, нарушен внутренний инвариант структуры данных, или функция получила аргумент, который по логике программы «никогда не должен быть таким».
Идея простая: если неуспех — нормальный сценарий, его удобнее сделать видимым в типе результата. Если неуспех — «так быть не должно», его часто логичнее делать исключением, потому что продолжать как ни в чём не бывало опасно.
2. Когда исключения уместны
Исключения любят за то, что они позволяют не протаскивать «провода ошибок» через каждую функцию и каждый if. Но уместны они не всегда. Здесь важна дисциплина: исключение — это сигнал «мы не можем продолжать нормальную работу по этому пути».
Практическое правило: исключения хороши для редких, «ненормальных» ситуаций, особенно когда решение о реакции на ошибку находится выше по коду. То есть там, где на нижнем уровне функции просто нечего делать, кроме как сказать: «у нас проблема, решайте наверху».
Нарушение контракта аргумента
Когда вы пишете функцию, вы часто предполагаете некоторые условия на вход. Например: «id задачи должен быть положительным», «строка команды не должна быть пустой», «вектор индексов не должен быть пустым, иначе нечего удалять». Если это обязательные условия, то нарушение — хороший кандидат на исключение std::invalid_argument.
Мини‑пример в стиле «контракт нарушен»:
#include <stdexcept>
#include <string>
void require_non_empty(const std::string& s) {
if (s.empty()) {
throw std::invalid_argument{"command must not be empty"};
}
}
Здесь важно не путать две ситуации. Если пользователь ввёл пустую строку в CLI — это может быть ожидаемо, и вы можете обработать это без исключения. Но если ваша внутренняя функция «получить команду» по архитектуре гарантирует непустую строку (например, вы уже фильтровали ввод), то пустая строка означает, что код сломан на уровне логики программы.
Внутренние инварианты: программа не должна была сюда попасть
Есть ошибки, которые в нормальной жизни пользователя происходить не должны. Типичный пример — несогласованное состояние контейнера, «битый» объект, невозможная ветка switch. В таком случае часто лучше «взорваться» исключением (или хотя бы не продолжать как будто всё хорошо), потому что дальнейшая работа может сделать хуже: сохранить неправильные данные, удалить не то, показать пользователю ложь.
Например, представим, что у нас есть задачи, и у каждой должен быть уникальный id. Если вы внезапно нашли две задачи с одинаковым id, это не «ошибка пользователя» — это ошибка вашей логики:
#include <stdexcept>
#include <vector>
struct Task { int id{}; };
void ensure_unique_ids(const std::vector<Task>& tasks) {
for (std::size_t i = 0; i < tasks.size(); ++i) {
for (std::size_t j = i + 1; j < tasks.size(); ++j) {
if (tasks[i].id == tasks[j].id) {
throw std::runtime_error{"duplicate task id detected"};
}
}
}
}
Код не идеален по сложности (два цикла), но для иллюстрации годится: исключение здесь уместно, потому что продолжение работы с «битым» набором задач приведёт к хаосу.
Ошибка в глубине и граница обработки
На практике исключения особенно полезны, когда ошибка возникает глубоко (например, при разборе команды), а решение, что делать дальше, находится на верхнем уровне — обычно в main() или в «командном цикле».
Схема выглядит так:
flowchart TD
A[parseCommand] -->|ok| B[executeCommand]
A -->|throw ParseError| C[main / command loop]
B -->|ok| C
B -->|throw runtime_error| C
C --> D[печать сообщения и продолжение цикла]
Идея: низкие функции не решают UX, они лишь сообщают, что произошло. А main() решает: показать ошибку, попросить пользователя повторить ввод, выйти из программы.
3. Когда лучше «ошибка как значение»
Если исключение — это «прыжок», то ошибка как значение — это «возврат с пометкой». И часто это намного честнее для читателя кода, потому что по сигнатуре функции сразу видно: «может не получиться».
Правило: если неуспех является нормальным исходом и его ожидают часто, лучше сделать его частью возвращаемого значения. Это снижает сюрпризы и делает обработку ошибок более прозрачной (и для вас, и для будущего вас, который уже всё забыл).
std::optional: когда важен только факт «есть/нет»
std::optional<T> хорош, когда у результата два состояния: «значение есть» и «значения нет», и при этом причина отсутствия не важна или известна из контекста.
Классический пример — поиск. Если пользователь просит «показать задачу с id=10», а такой задачи нет — это не катастрофа, это обычный сценарий.
#include <optional>
#include <vector>
struct Task { int id{}; };
std::optional<std::size_t> find_task_index(const std::vector<Task>& tasks, int id) {
for (std::size_t i = 0; i < tasks.size(); ++i) {
if (tasks[i].id == id) return i;
}
return std::nullopt;
}
Обратите внимание: мы не бросаем исключение, потому что «не найдено» — нормальная ветка, особенно в интерактивной программе. Исключения здесь только усложнят жизнь: вам придётся оборачивать обычные действия пользователя в try/catch, а это уже похоже на сериал «угадай, где сегодня вылетит».
std::variant: когда исходов несколько и все осмысленные
Иногда «не получилось» бывает разным, и причины важны: пользователь ввёл неизвестную команду, ввёл команду правильную, но с плохим форматом аргумента, или команда корректна. Если вы хотите, чтобы это было видно в типе, удобно использовать std::variant.
Мини-модель парсинга команды для нашего консольного приложения задач (условно назовём его TaskCLI). Команды будем делать простыми: ADD текст, DONE id, LIST.
#include <string>
#include <variant>
struct AddCmd { std::string text; };
struct DoneCmd { int id{}; };
struct ListCmd { };
struct ParseError { std::string message; };
using ParseResult = std::variant<AddCmd, DoneCmd, ListCmd, ParseError>;
Почему это «ошибка как значение»? Потому что ParseError — такой же вариант результата, как и AddCmd. Мы не «прыгаем» через стек, мы говорим: «вот что получилось распарсить».
И да, variant тоже стандартный инструмент, который проектировался как «типобезопасный союз».
Пользовательский ввод и частые ошибки
Пользовательский ввод плох тем, что пользователь — живой человек. А человек может ввести DONE котик вместо DONE 5. Если вы будете бросать исключение на каждую такую ошибку, то try/catch станет вашим основным циклом жизни. Это не всегда плохо, но чаще всего избыточно: пользовательские ошибки — ожидаемы и часты, а значит, лучше возвращать понятный результат и печатать сообщение.
Мини-пример: парсим int без исключений, возвращая optional:
#include <optional>
#include <string>
std::optional<int> parse_int(const std::string& s) {
if (s.empty()) return std::nullopt;
try {
return std::stoi(s);
} catch (...) {
return std::nullopt;
}
}
Тут мы «локально» используем исключение (stoi бросает), но наружу отдаём спокойный optional. Это уже пример комбинирования подходов (к нему ещё вернёмся).
4. Комбинирование подходов: внутри прыгай, наружу будь вежливым
Реальные программы редко живут в одной религии. Часто наиболее здоровый дизайн — это когда внутри модулей вы используете исключения там, где они действительно помогают не тащить проверку на ошибку через каждый уровень, а на границе (например, в обработчике команд) вы превращаете их в понятный результат или сообщение.
Ниже — два популярных компромисса: «исключения внутри, результат наружу» и «результат внутри, исключение только для инвариантов».
Исключение внутри, понятный результат наружу
Представьте, что у вас уже есть строгий парсер, который бросает исключение ParseError (мы делали свой тип в предыдущей лекции). Но ваш UI/CLI-код хочет работать без try/catch в каждой строчке. Пишем адаптер:
#include <string>
#include <variant>
class ParseException; // допустим, это ваш тип исключения
std::variant<int, std::string> safe_parse_id(const std::string& s) {
try {
return std::stoi(s);
} catch (...) {
return std::string{"id must be a number"};
}
}
Да, это variant<int, string> — примитивный «успех/ошибка». Мы не вводим новые темы, просто используем уже знакомый variant: слева успех, справа текст ошибки. И теперь любой код выше может обработать это без исключений.
Ошибка как значение для обычного, исключение — для невозможного
Очень полезная привычка: задавать себе вопрос «а если я продолжу работу, результат будет корректным?». Если да — вероятно, это не исключение. Если нет — исключение уместно.
Например, команда DONE 10: если задачи 10 нет — это не катастрофа, просто говорим пользователю «не найдено». Но если вдруг id отрицательный, хотя по контракту id всегда положительный, это уже подозрительно: значит, где-то вы не проверили ввод, или вы храните мусор.
#include <stdexcept>
#include <vector>
struct Task { int id{}; bool done{}; };
void mark_done(std::vector<Task>& tasks, int id) {
if (id <= 0) {
throw std::invalid_argument{"task id must be positive"};
}
for (auto& t : tasks) {
if (t.id == id) {
t.done = true;
return;
}
}
// "не найдено" — не исключение, решим выше
}
Функция «не нашла» и просто молча вернулась. Это спорно, но иногда допустимо, если контракт функции так и задуман: «попробовать отметить выполненной». А вот отрицательный id — явно плохой вход.
5. Где ловить исключения в TaskCLI
Одна из самых неприятных ошибок новичка — либо не ловить исключения вообще («пусть падает, так честнее»), либо ловить их в каждой функции («чтобы точно не упало»). На практике нужен третий путь: ловить там, где вы можете принять решение, то есть на границе сценария.
Для консольного приложения задач удобная граница — обработка одной команды. Мы хотим: одна команда не должна ронять всю программу, максимум — печать ошибки и продолжение цикла.
Скелет командного цикла:
#include <exception>
#include <iostream>
#include <string>
int main() {
try {
std::string line;
while (std::getline(std::cin, line)) {
// parse + execute одной команды
}
} catch (const std::exception& e) {
std::cout << "fatal: " << e.what() << '\n';
}
}
Это «последний рубеж»: если мы не обработали что-то на уровне команды, то хотя бы напечатаем причину и не оставим пользователя с молчаливым terminate.
А внутри цикла часто удобно сделать ещё один try/catch, но уже для одной команды, чтобы не выбивать весь цикл:
#include <exception>
#include <iostream>
#include <string>
void process_line(const std::string& line); // ваша логика
int main() {
std::string line;
while (std::getline(std::cin, line)) {
try {
process_line(line);
} catch (const std::exception& e) {
std::cout << "error: " << e.what() << '\n';
}
}
}
Такой дизайн создаёт понятную семантику: «ошибка в одной команде — это просто сообщение, а не конец света».
6. Мини-шпаргалка выбора
Пока вы начинаете, очень помогает иметь «внутреннюю шпаргалку», потому что иначе мозг будет выбирать между throw и optional примерно как между пиццей и суши: «хочу всё, и желательно вчера». Здесь не будет философии, будет практическая математика для повседневной жизни.
Таблица решений
| Ситуация в коде | Что выбрать | Почему |
|---|---|---|
| «Не найдено» при поиске | |
Это нормальный исход, не надо делать из него ЧП |
| Некорректный ввод пользователя (часто) | |
Ожидаемо и регулярно; удобно дать понятное сообщение |
| Нарушен контракт аргумента (по логике программы не должен нарушаться) | |
Это ошибка уровня дизайна/логики вызова |
| Внутренний инвариант сломан | |
Продолжать опасно, состояние подозрительное |
| Ошибка возникает глубоко, а решение — наверху | Исключение + ловить на границе | Не тащим проверку на каждый уровень |
Блок‑схема из двух вопросов
flowchart TD
A[Ошибка возможна?] -->|нет| B[Обычный return]
A -->|да| C[Это ожидаемая ветка?]
C -->|да| D[Ошибка как значение: optional/variant]
C -->|нет| E[Можно продолжать корректно?]
E -->|да| D
E -->|нет| F[Исключение: throw]
Смысл этой схемы: не надо начинать с вопроса «я люблю исключения или optional?». Начинайте с вопроса «это нормальная жизнь функции или авария?».
7. Практический пример: парсинг и выполнение
Теперь соберём маленький кусочек TaskCLI так, чтобы было видно, как подходы живут вместе. Мы сделаем parse_command, который возвращает ParseResult (ошибка как значение), а execute_command может бросить исключение на нарушении инварианта (исключение как авария).
Парсинг: ошибка как значение
#include <string>
#include <variant>
struct AddCmd { std::string text; };
struct ListCmd { };
struct ParseError { std::string message; };
using ParseResult = std::variant<AddCmd, ListCmd, ParseError>;
ParseResult parse_command(const std::string& line) {
if (line == "LIST") return ListCmd{};
if (line.rfind("ADD ", 0) == 0) return AddCmd{ line.substr(4) };
return ParseError{"unknown command"};
}
Заметьте, как легко читать: функция не «прыгает», она просто возвращает один из вариантов. Для CLI это часто идеально.
Выполнение: исключение для невозможного
Допустим, у нас есть инвариант: текст задачи не должен быть пустым (по логике приложения «пустая задача» бессмысленна). Тогда execute может бросить исключение, если туда дошло пустое значение (то есть парсер или вызывающий код пропустили проверку).
#include <stdexcept>
#include <string>
#include <vector>
struct Task { int id{}; std::string text; };
void add_task(std::vector<Task>& tasks, const std::string& text) {
if (text.empty()) throw std::invalid_argument{"task text is empty"};
tasks.push_back(Task{static_cast<int>(tasks.size() + 1), text});
}
Здесь throw — это «сирена»: если мы дошли до пустого текста, значит, в программе дыра, и лучше эту дыру подсветить.
8. Типичные ошибки
Ошибка №1: использовать исключения для «не найдено».
Когда студент впервые понимает throw, появляется соблазн бросать исключение везде: не нашли задачу — throw, не нашли команду — throw, пользователь нажал Enter на пустой строке — тоже throw (на всякий случай). Проблема в том, что поиск и «не найдено» — обычная ветка. Если делать её исключением, ваш try/catch превращается в основной управляющий механизм интерфейса, и код становится тяжелее читать и тестировать.
Ошибка №2: возвращать «магические значения» вместо явного результата.
Возвращать -1 как «ошибка» для индекса, пустую строку как «ошибка» для имени, 0 как «ошибка» для id — это классика жанра. Она работает до первого случая, когда -1 тоже становится допустимым значением (или когда кто-то забывает проверить). std::optional и std::variant хороши тем, что ошибка перестаёт быть тайной и становится частью типа.
Ошибка №3: ловить исключение слишком рано и «глотать» его молча.
Иногда пишут catch (...) {} — и программа продолжает работать, как будто ничего не случилось. Это хуже, чем падение: вы теряете сигнал о проблеме и получаете «тихую порчу» состояния. Если вы ловите исключение, сделайте минимум: напечатайте what() или пробросьте дальше, если не знаете, что делать.
Ошибка №4: печатать сообщение об ошибке на каждом уровне.
Низкоуровневая функция пишет в std::cout «ошибка!», затем более высокий уровень тоже пишет «ошибка!», затем main() добавляет «ошибка!» — и пользователь получает тройной крик души. Гораздо чище держать правило: либо функция возвращает ошибку как значение и не печатает, либо бросает исключение и тоже не печатает; печать делается на границе (например, в обработке команды).
Ошибка №5: смешивать стили без договорённости и получать «лоскутное API».
Если одна функция «на не найдено» возвращает nullopt, другая на не найдено бросает out_of_range, третья возвращает -1, а четвёртая печатает в консоль и возвращает false, то пользоваться этим набором почти невозможно: каждый вызов надо помнить отдельно. Даже в учебном проекте стоит выбрать простую политику: пользовательские ошибки — значением, невозможные состояния — исключением, печать — на границе сценария.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ