⚙️ C++

Обработка ошибок и исключений в C++: подробное руководство

Что это такое и зачем? (аналогия из реальной жизни)

Представь, что ты — шеф-повар на большой кухне. Ты готовишь сложное блюдо по рецепту. Всё идёт хорошо, пока вдруг... не заканчивается мука. Или ломается миксер. Или кто-то случайно переключил духовку на другой режим.

Что делает опытный повар? Он не бросает всё и не начинает кричать. У него есть план Б:

  • Если нет муки — он использует другую.
  • Если сломался миксер — он мешает вручную.
  • Если духовка сбилась — он подстраивает время.

В программировании происходит то же самое. Твой код — это рецепт. Компьютер — это кухня. Но компьютер — не идеальный повар. Он может:

  • Не найти файл, который ты просишь открыть.
  • Попытаться поделить число на ноль.
  • Закончить память.
  • Получить от пользователя "пять" вместо числа.

Если не подготовиться к таким ситуациям, программа просто упадёт (вылетит с ошибкой). Пользователь увидит страшное окно с непонятным текстом и, скорее всего, закроет твою программу навсегда.

Обработка ошибок и исключений — это твой "план Б". Это способ сказать программе: "Если что-то пошло не так — не паникуй, сделай вот это, а потом продолжай работать".

Важно: Обработка ошибок — это не про то, чтобы скрыть проблему. Это про то, чтобы программа оставалась под контролем даже в непредвиденных ситуациях.

Теория: как это работает изнутри

Давай заглянем "под капот" и разберёмся, что происходит в компьютере, когда возникает ошибка.

Стек вызовов (Call Stack)

Когда программа работает, компьютер запоминает, какие функции были вызваны, в виде стопки — стека вызовов. Представь стопку тарелок:

  1. Сначала ты вызываешь функцию main() — это первая тарелка.
  2. Из main() ты вызываешь cookPasta() — кладёшь вторую тарелку сверху.
  3. Из cookPasta() ты вызываешь boilWater() — третья тарелка.

Если в boilWater() что-то пошло не так, компьютер начинает "снимать тарелки" (завершать функции) одну за другой, пока не дойдёт до main(). Если и там нет обработчика — программа падает.

Механизм исключений

Исключение (exception) — это специальный объект, который "выбрасывается" (throw) в месте, где произошла ошибка. Этот объект путешествует вверх по стеку вызовов, пока не найдёт подходящий "перехватчик" (catch).

Процесс выглядит так:

  1. Попытка (try): Ты помечаешь блок кода, где может произойти ошибка.
  2. Выброс (throw): Если ошибка случилась, ты создаёшь объект-исключение и "выбрасываешь" его.
  3. Перехват (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. Используйте исключения только для исключительных ситуаций