Обработка ошибок и исключений в C++: подробное руководство
Что это такое и зачем? (аналогия из реальной жизни)
Представь, что ты — шеф-повар на большой кухне. Ты готовишь сложное блюдо по рецепту. Всё идёт хорошо, пока вдруг... не заканчивается мука. Или ломается миксер. Или кто-то случайно переключил духовку на другой режим.
Что делает опытный повар? Он не бросает всё и не начинает кричать. У него есть план Б:
- Если нет муки — он использует другую.
- Если сломался миксер — он мешает вручную.
- Если духовка сбилась — он подстраивает время.
В программировании происходит то же самое. Твой код — это рецепт. Компьютер — это кухня. Но компьютер — не идеальный повар. Он может:
- Не найти файл, который ты просишь открыть.
- Попытаться поделить число на ноль.
- Закончить память.
- Получить от пользователя "пять" вместо числа.
Если не подготовиться к таким ситуациям, программа просто упадёт (вылетит с ошибкой). Пользователь увидит страшное окно с непонятным текстом и, скорее всего, закроет твою программу навсегда.
Обработка ошибок и исключений — это твой "план Б". Это способ сказать программе: "Если что-то пошло не так — не паникуй, сделай вот это, а потом продолжай работать".
Важно: Обработка ошибок — это не про то, чтобы скрыть проблему. Это про то, чтобы программа оставалась под контролем даже в непредвиденных ситуациях.
Теория: как это работает изнутри
Давай заглянем "под капот" и разберёмся, что происходит в компьютере, когда возникает ошибка.
Стек вызовов (Call Stack)
Когда программа работает, компьютер запоминает, какие функции были вызваны, в виде стопки — стека вызовов. Представь стопку тарелок:
- Сначала ты вызываешь функцию
main()— это первая тарелка. - Из
main()ты вызываешьcookPasta()— кладёшь вторую тарелку сверху. - Из
cookPasta()ты вызываешьboilWater()— третья тарелка.
Если в boilWater() что-то пошло не так, компьютер начинает "снимать тарелки" (завершать функции) одну за другой, пока не дойдёт до main(). Если и там нет обработчика — программа падает.
Механизм исключений
Исключение (exception) — это специальный объект, который "выбрасывается" (throw) в месте, где произошла ошибка. Этот объект путешествует вверх по стеку вызовов, пока не найдёт подходящий "перехватчик" (catch).
Процесс выглядит так:
- Попытка (try): Ты помечаешь блок кода, где может произойти ошибка.
- Выброс (throw): Если ошибка случилась, ты создаёшь объект-исключение и "выбрасываешь" его.
- Перехват (catch): Ты указываешь, какой тип исключения ловить и что с ним делать.
Важно: Если исключение не перехвачено нигде, программа вызывает специальную функцию std::terminate(), которая аварийно завершает программу. Именно этого мы хотим избежать.
Типы исключений
В C++ можно выбрасывать что угодно: числа, строки, объекты. Но профессионалы используют специальные классы исключений. Стандартная библиотека C++ предоставляет иерархию классов:
| Класс | Что означает | Пример |
|---|---|---|
std::exception |
Базовый класс для всех исключений | Родитель всех остальных |
std::runtime_error |
Ошибка, которую можно обнаружить только во время работы программы | Файл не найден |
std::logic_error |
Ошибка в логике программы | Неверный аргумент функции |
std::out_of_range |
Выход за границы массива или строки | Обращение к 10-му элементу массива из 5 |
std::bad_alloc |
Не удалось выделить память | Слишком большой массив |
Синтаксис в C++: все формы и варианты
В C++ есть три ключевых слова для работы с исключениями: try, throw и catch. Давай разберём каждое.
Базовая конструкция
#include <iostream>
#include <stdexcept> // Для стандартных исключений
int main() {
try {
// Здесь пишем код, который может вызвать ошибку
int age;
std::cout << "Введите возраст: ";
std::cin >> age;
if (age < 0) {
// Если возраст отрицательный — выбрасываем исключение
throw std::invalid_argument("Возраст не может быть отрицательным!");
}
std::cout << "Ваш возраст: " << age << std::endl;
}
catch (const std::invalid_argument& e) {
// Ловим исключение типа invalid_argument
std::cout << "Ошибка: " << e.what() << std::endl;
}
catch (const std::exception& e) {
// Ловим ЛЮБОЕ другое стандартное исключение
std::cout << "Неизвестная ошибка: " << e.what() << std::endl;
}
// Программа продолжает работать!
std::cout << "Программа завершена успешно." << std::endl;
return 0;
}
Разные способы выбрасывания (throw)
// Способ 1: Выбросить строку
throw "Это строка-ошибка";
// Способ 2: Выбросить число
throw 42;
// Способ 3: Выбросить стандартное исключение
throw std::runtime_error("Что-то пошло не так");
// Способ 4: Выбросить собственный класс
class MyException {
public:
std::string message;
int errorCode;
MyException(std::string msg, int code) : message(msg), errorCode(code) {}
};
throw MyException("Своя ошибка", 404);
Множественные catch-блоки
try {
// Какой-то код
throw std::out_of_range("Выход за границы");
}
catch (const std::out_of_range& e) {
// Сначала ловим конкретные исключения
std::cerr << "Ошибка диапазона: " << e.what() << std::endl;
}
catch (const std::runtime_error& e) {
// Потом более общие
std::cerr << "Ошибка выполнения: " << e.what() << std::endl;
}
catch (const std::exception& e) {
// Потом совсем общие
std::cerr << "Стандартная ошибка: " << e.what() << std::endl;
}
catch (...) {
// Ловим ВСЁ, что не поймали выше
std::cerr << "Неизвестная ошибка!" << std::endl;
}
Важно: Блоки catch проверяются в порядке их написания. Первый подходящий блок перехватывает исключение. Поэтому сначала пишем самые конкретные типы, потом — более общие.
Вложенные try-catch
try {
try {
// Внутренний блок
throw std::runtime_error("Внутренняя ошибка");
}
catch (const std::runtime_error& e) {
std::cout << "Поймали внутри: " << e.what() << std::endl;
// Можем выбросить снова, чтобы поймали снаружи
throw; // Повторный выброс того же исключения
}
}
catch (const std::runtime_error& e) {
std::cout << "Поймали снаружи: " << e.what() << std::endl;
}
Спецификация исключений (устаревшая, но встречается)
// Старый стиль (C++98) — не используйте в новом коде!
void oldFunction() throw(std::runtime_error, std::logic_error) {
// Эта функция может выбросить только эти два типа
}
// Новый стиль (C++11 и новее)
void newFunction() noexcept {
// Эта функция НЕ выбрасывает исключения
// Если исключение всё же выбросится — программа упадёт
}
void maybeThrows() noexcept(false) {
// Эта функция МОЖЕТ выбрасывать исключения
}
Примеры кода — от простого к сложному
Пример 1: Деление на ноль
#include <iostream>
#include <stdexcept>
double divide(int a, int b) {
// Проверяем, не равен ли делитель нулю
if (b == 0) {
// Если равен — выбрасываем исключение
throw std::runtime_error("Деление на ноль!");
}
// Если всё хорошо — возвращаем результат
return static_cast<double>(a) / b;
}
int main() {
try {
// Пробуем разделить 10 на 0
double result = divide(10, 0);
std::cout << "Результат: " << result << std::endl;
}
catch (const std::exception& e) {
// Ловим ошибку и сообщаем пользователю
std::cout << "Произошла ошибка: " << e.what() << std::endl;
}
// Программа продолжает работу!
std::cout << "Программа завершена." << std::endl;
return 0;
}
Пример 2: Ввод числа от пользователя
#include <iostream>
#include <limits> // Для numeric_limits
int getPositiveNumber() {
int number;
std::cout << "Введите положительное число: ";
std::cin >> number;
// Проверяем, что ввод был успешным
if (std::cin.fail()) {
// Очищаем поток от мусора
std::cin.clear();
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '
');
throw std::invalid_argument("Введено не число!");
}
// Проверяем, что число положительное
if (number <= 0) {
throw std::out_of_range("Число должно быть положительным!");
}
return number;
}
int main() {
try {
int num = getPositiveNumber();
std::cout << "Вы ввели: " << num << std::endl;
}
catch (const std::exception& e) {
std::cout << "Ошибка ввода: " << e.what() << std::endl;
}
return 0;
}
Пример 3: Работа с файлом
#include <iostream>
#include <fstream> // Для работы с файлами
#include <string>
std::string readFile(const std::string& filename) {
std::ifstream file(filename); // Пытаемся открыть файл
// Проверяем, открылся ли файл
if (!file.is_open()) {
throw std::runtime_error("Не удалось открыть файл: " + filename);
}
std::string content;
std::string line;
// Читаем файл построчно
while (std::getline(file, line)) {
content += line + "
";
}
// Проверяем, не было ли ошибки при чтении
if (file.bad()) {
throw std::runtime_error("Ошибка при чтении файла");
}
file.close(); // Закрываем файл
return content;
}
int main() {
try {
std::string text = readFile("data.txt");
std::cout << "Содержимое файла:
" << text;
}
catch (const std::exception& e) {
std::cout << "Ошибка: " << e.what() << std::endl;
}
return 0;
}
Пример 4: Собственный класс исключения
#include <iostream>
#include <string>
#include <exception>
// Создаём свой класс исключения
class BankException : public std::exception {
private:
std::string message;
int errorCode;
public:
// Конструктор
BankException(const std::string& msg, int code)
: message(msg), errorCode(code) {}
// Переопределяем метод what() из std::exception
const char* what() const noexcept override {
return message.c_str();
}
// Добавляем свой метод
int getErrorCode() const {
return errorCode;
}
};
class BankAccount {
private:
double balance;
public:
BankAccount(double initialBalance) : balance(initialBalance) {
if (initialBalance < 0) {
throw BankException("Начальный баланс не может быть отрицательным", 1001);
}
}
void withdraw(double amount) {
if (amount <= 0) {
throw BankException("Сумма снятия должна быть положительной", 1002);
}
if (amount > balance) {
throw BankException("Недостаточно средств", 1003);
}
balance -= amount;
}
double getBalance() const {
return balance;
}
};
int main() {
try {
BankAccount account(1000);
account.withdraw(1500); // Пытаемся снять больше, чем есть
}
catch (const BankException& e) {
std::cout << "Банковская ошибка: " << e.what() << std::endl;
std::cout << "Код ошибки: " << e.getErrorCode() << std::endl;
}
return 0;
}
Пример 5: RAII и исключения
#include <iostream>
#include <fstream>
#include <vector>
// RAII (Resource Acquisition Is Initialization) —
// ресурс захватывается в конструкторе и освобождается в деструкторе
class FileHandler {
private:
std::ofstream file;
public:
FileHandler(const std::string& filename) {
file.open(filename);
if (!file.is_open()) {
throw std::runtime_error("Не могу открыть файл");
}
std::cout << "Файл открыт" << std::endl;
}
void write(const std::string& data) {
if (file.bad()) {
throw std::runtime_error("Файл в плохом состоянии");
}
file << data << std::endl;
}
// Деструктор автоматически закроет файл, даже если было исключение
~FileHandler() {
if (file.is_open()) {
file.close();
std::cout << "Файл закрыт" << std::endl;
}
}
// Запрещаем копирование
FileHandler(const FileHandler&) = delete;
FileHandler& operator=(const FileHandler&) = delete;
};
int main() {
try {
FileHandler handler("test.txt");
handler.write("Первая строка");
handler.write("Вторая строка");
// Если здесь произойдёт исключение, деструктор всё равно закроет файл
throw std::runtime_error("Неожиданная ошибка!");
handler.write("Третья строка"); // Эта строка не выполнится
}
catch (const std::exception& e) {
std::cout << "Поймали: " << e.what() << std::endl;
}
// Файл будет закрыт автоматически!
return 0;
}
Пример 6: Исключения в конструкторах
#include <iostream>
#include <vector>
#include <memory>
class Resource {
public:
Resource() {
std::cout << "Ресурс создан" << std::endl;
}
~Resource() {
std::cout << "Ресурс уничтожен" << std::endl;
}
};
class SafeClass {
private:
Resource* res1;
Resource* res2;
public:
SafeClass() {
try {
res1 = new Resource();
res2 = new Resource();
// Если new выбросит bad_alloc, мы должны очистить res1
}
catch (...) {
delete res1; // Очищаем то, что успели создать
throw; // Перебрасываем исключение дальше
}
}
// Лучше использовать умные указатели!
SafeClass(const SafeClass&) = delete;
SafeClass& operator=(const SafeClass&) = delete;
~SafeClass() {
delete res1;
delete res2;
}
};
// Современный подход с умными указателями
class ModernClass {
private:
std::unique_ptr<Resource> res1;
std::unique_ptr<Resource> res2;
public:
ModernClass()
: res1(std::make_unique<Resource>())
, res2(std::make_unique<Resource>()) {
// Если make_unique выбросит исключение, unique_ptr сам всё очистит
}
// Деструктор не нужен — unique_ptr сам всё удалит
};
int main() {
try {
ModernClass obj;
std::cout << "Объект создан успешно" << std::endl;
}
catch (const std::exception& e) {
std::cout << "Ошибка: " << e.what() << std::endl;
}
return 0;
}
Пример 7: Полный пример с логированием
#include <iostream>
#include <fstream>
#include <string>
#include <chrono>
#include <ctime>
// Класс для логирования ошибок
class Logger {
private:
std::ofstream logFile;
std::string getCurrentTime() {
auto now = std::chrono::system_clock::now();
auto time_t = std::chrono::system_clock::to_time_t(now);
return std::ctime(&time_t);
}
public:
Logger(const std::string& filename) {
logFile.open(filename, std::ios::app); // Открываем для добавления
if (!logFile.is_open()) {
throw std::runtime_error("Не могу открыть файл лога");
}
}
void logError(const std::string& error) {
logFile << "[" << getCurrentTime() << "] ERROR: " << error << std::endl;
}
void logInfo(const std::string& info) {
logFile << "[" << getCurrentTime() << "] INFO: " << info << std::endl;
}
~Logger() {
if (logFile.is_open()) {
logFile.close();
}
}
};
// Функция для безопасного деления с логированием
double safeDivide(int a, int b, Logger& logger) {
if (b == 0) {
logger.logError("Попытка деления на ноль: " + std::to_string(a) + " / 0");
throw std::runtime_error("Деление на ноль");
}
double result = static_cast<double>(a) / b;
logger.logInfo("Успешное деление: " + std::to_string(a) + " / " + std::to_string(b) + " = " + std::to_string(result));
return result;
}
int main() {
try {
Logger logger("app.log");
logger.logInfo("Программа запущена");
// Пробуем разные операции
std::cout << "10 / 2 = " << safeDivide(10, 2, logger) << std::endl;
std::cout << "10 / 0 = " << safeDivide(10, 0, logger) << std::endl; // Здесь будет ошибка
}
catch (const std::exception& e) {
std::cout << "Произошла ошибка: " << e.what() << std::endl;
std::cout << "Подробности в файле app.log" << std::endl;
}
std::cout << "Программа завершена" << std::endl;
return 0;
}
Сравнение с Python: чем похоже и чем отличается
Если ты уже знаком с Python, тебе будет проще понять исключения в C++. Давай сравним их.
| Характеристика | C++ | Python |
|---|---|---|
| Ключевые слова | try, catch, throw |
try, except, raise |
| Что можно выбрасывать | Всё что угодно (числа, строки, объекты) | Только объекты, наследующие BaseException |
| Блок finally | Нет встроенного, но можно сделать через RAII | Есть finally |
| Производительность | Исключения дорогие (разворачивание стека) | Тоже дорогие, но по-другому |
| Проверка типов | Статическая (на этапе компиляции) | Динамическая (на этапе выполнения) |
| Стандартные исключения | std::exception и наследники |
Exception и наследники |
| Ловля всех исключений | catch (...) |
except: |
Пример на Python для сравнения
# Python версия
def divide(a, b):
if b == 0:
raise ValueError("Деление на ноль!")
return a / b
try:
result = divide(10, 0)
print(f"Результат: {result}")
except ValueError as e:
print(f"Ошибка: {e}")
except Exception as e:
print(f"Неизвестная ошибка: {e}")
finally:
print("Этот блок выполнится всегда")
Та же логика на C++
#include <iostream>
#include <stdexcept>
double divide(int a, int b) {
if (b == 0) {
throw std::invalid_argument("Деление на ноль!");
}
return static_cast<double>(a) / b;
}
int main() {
try {
double result = divide(10, 0);
std::cout << "Результат: " << result << std::endl;
}
catch (const std::invalid_argument& e) {
std::cout << "Ошибка: " << e.what() << std::endl;
}
catch (const std::exception& e) {
std::cout << "Неизвестная ошибка: " << e.what() << std::endl;
}
// В C++ нет finally, но RAII решает ту же задачу
std::cout << "Этот блок выполнится всегда" << std::endl;
return 0;
}
Важно: В C++ нет блока finally. Вместо этого используется идиома RAII (Resource Acquisition Is Initialization). Объекты автоматически уничтожаются при выходе из области видимости, даже если было исключение.
Типичные ошибки и подводные камни
Ошибка 1: Ловить всё подряд без разбора
// ПЛОХО: Ловим всё, но ничего не делаем
try {
// какой-то код
}
catch (...) {
// Игнорируем ошибку — программа продолжает работать, но в неверном состоянии
}
// ХОРОШО: Ловим конкретные исключения и реагируем
try {
// какой-то код
}
catch (const std::invalid_argument& e) {
std::cerr << "Неверный аргумент: " << e.what() << std::endl;
throw; // Если не можем обработать — перебрасываем дальше
}
Ошибка 2: Выбрасывать исключения в деструкторах
class BadClass {
public:
~BadClass() {
// ПЛОХО: Никогда не выбрасывайте исключения из деструктора!
throw std::runtime_error("Ошибка в деструкторе");
// Если деструктор вызван из-за другого исключения — программа упадёт
}
};
Ошибка 3: Утечка ресурсов
// ПЛОХО: Если new выбросит исключение, память не освободится
void badFunction() {
int* arr = new int[100];
// Если здесь произойдёт исключение — память утечёт
someRiskyOperation();
delete[] arr; // Эта строка может не выполниться
}
// ХОРОШО: Используем умные указатели
void goodFunction() {
std::unique_ptr<int[]> arr(new int[100]);
someRiskyOperation(); // Если исключение — unique_ptr сам всё очистит
}
Ошибка 4: Неправильный порядок catch-блоков
// ПЛОХО: Сначала общий, потом частный
try {
throw std::invalid_argument("Ошибка");
}
catch (const std::exception& e) { // Этот поймает ВСЁ
std::cout << "Общая ошибка" << std::endl;
}
catch (const std::invalid_argument& e) { // Этот НИКОГДА не выполнится
std::cout << "Неверный аргумент" << std::endl;
}
// ХОРОШО: Сначала частный, потом общий
try {
throw std::invalid_argument("Ошибка");
}
catch (const std::invalid_argument& e) { // Этот поймает
std::cout << "Неверный аргумент" << std::endl;
}
catch (const std::exception& e) { // Этот поймает всё остальное
std::cout << "Общая ошибка" << std::endl;
}
Ошибка 5: Игнорирование исключений в конструкторах
// ПЛОХО: Конструктор может оставить объект в невалидном состоянии
class FileReader {
std::ifstream file;
public:
FileReader(const std::string& name) {
file.open(name);
// Если файл не открылся — объект существует, но нерабочий
}
void read() {
if (!file.is_open()) {
// Приходится проверять в каждом методе
throw std::runtime_error("Файл не открыт");
}
}
};
// ХОРОШО: Конструктор выбрасывает исключение при ошибке
class SafeFileReader {
std::ifstream file;
public:
SafeFileReader(const std::string& name) {
file.open(name);
if (!file.is_open()) {
throw std::runtime_error("Не могу открыть файл: " + name);
}
}
};
Лучшие практики и советы профессионалов
1. Используйте исключения только для исключительных ситуаций