1. Почему при исключении ресурсы освобождаются сами
Если до этого момента исключения казались вам чем-то вроде «прыжка в портал», то вы не одиноки: исключение действительно прерывает обычный поток выполнения. Но C++ при этом ведёт себя довольно дисциплинированно: он «разматывает» стек вызовов, и на пути назад вызывает деструкторы всех локальных объектов. Именно поэтому RAII работает так хорошо — не по магии, а по расписанию.
Представьте, что функция — это комната, а локальные переменные — предметы, которые вы занесли внутрь. Когда вы выходите из комнаты, вы обязаны унести предметы обратно. Исключение — это когда вас внезапно «эвакуировали»: вы не дошли до двери обычным способом, но вас всё равно вывели, и предметы по пути «собрали» автоматически (деструкторы).
Мини-пример, чтобы почувствовать механику:
#include <iostream>
#include <stdexcept>
struct Guard {
~Guard() { std::cout << "Guard: cleanup\n"; }
};
void demo() {
Guard g;
std::cout << "Before throw\n";
throw std::runtime_error("boom");
// сюда мы не дойдём
}
int main() {
try {
demo();
} catch (const std::exception& e) {
std::cout << "Caught: " << e.what() << '\n';
}
}
Здесь при исключении Guard всё равно «уберётся». Это и есть базовая причина, почему RAII спасает ресурсы: файл закроется, мьютекс разблокируется, память освободится — потому что у владельца ресурса вызовется деструктор.
2. Ресурсная корректность и логическая корректность
Самая неприятная ловушка для новичка в современном C++ выглядит так: «Ну раз RAII, значит можно не бояться — всё будет хорошо». И действительно, ресурсно часто будет хорошо. Но «ресурсно хорошо» не означает «данные не испортились».
Давайте договоримся о терминах на уровне здравого смысла.
Ресурс — это то, что нужно освободить: память, дескриптор файла, блокировка, сетевое соединение. RAII говорит: «ресурс должен жить внутри объекта-владельца; разрушился владелец — освободился ресурс».
Состояние — это то, что должно быть осмысленным: поля класса, элементы контейнера, связи между ними. Состояние может быть полностью освобождено без утечек… и при этом быть логически сломанным.
Удобная мини-табличка:
| Что защищает RAII | Пример | Что это гарантирует | Что НЕ гарантирует |
|---|---|---|---|
| Ресурсы | |
«не утечёт» | «смысл операции сохранился» |
| Логику | инварианты, проверки, порядок обновления | «объект в корректном состоянии» | автоматически не возникает |
И вот тут начинается самое интересное: исключение может вылететь между двумя присваиваниями, и вы получите объект, где половина полей уже обновилась, а половина — нет.
Частичное обновление как источник проблем
Большинство реальных багов вокруг исключений — это не «мы забыли поймать исключение». Это «мы поймали / не поймали — не важно, но после исключения объект остался в странном состоянии, и через 20 минут оно аукнулось».
Схема типичной проблемы такая: метод делает несколько шагов, и один из шагов может бросить. Шаги до броска уже успели поменять состояние, а шаги после броска — нет, потому что до них не дошли.
Нарисуем это как простую блок-схему жизненного цикла «опасного» метода:
flowchart TD
A[Начало метода] --> B[Изменили часть полей]
B --> C[Опасная операция: может бросить]
C -->|успех| D[Изменили оставшиеся поля]
D --> E[Конец: состояние согласовано]
C -->|исключение| F[Выход из метода через исключение]
F --> G[Объект жив, но состояние может быть несогласованным]
Ключевая мысль: исключение “режет” выполнение по живому. Поэтому вопрос дня: что остаётся истинным про объект, если метод оборвался исключением?
3. Инвариант: правила корректного объекта
Чтобы не говорить «странное состояние», программисты любят слово «инвариант». Звучит как что-то из матанализа, но в программировании это обычно очень простая мысль: инвариант — это правила, которым объект обязан соответствовать всегда, когда его видят снаружи.
Например:
- «у пользователя имя не пустое»
- «вектор задач и счётчик задач показывают одно и то же количество»
- «индекс по id согласован с основным списком»
Смысл инварианта — не в красоте. Он нужен, чтобы вы могли честно сказать: «ОК, метод мог упасть исключением, но объект всё равно остался валидным».
Пока без формальных “гарантий” (их мы будем обсуждать в следующих лекциях дня), зафиксируем минимум: после исключения объект должен оставаться таким, что его можно безопасно дальше использовать или хотя бы безопасно уничтожить.
Пример: контейнер валиден, но операция применена частично
Чтобы увидеть проблему без классов, возьмём std::vector. Он хорош тем, что после исключения обычно остаётся валидным (то есть не разваливается). Но он может оказаться в “неожиданном” состоянии с точки зрения вашей операции.
#include <stdexcept>
#include <vector>
void push_then_fail(std::vector<int>& v) {
v.push_back(1); // добавили элемент
throw std::runtime_error("fail"); // операция прервалась
}
Здесь не будет утечки памяти: vector управляет памятью сам. Но логически могло подразумеваться «либо добавили оба элемента, либо ничего», а по факту — добавили один и упали. Вектор валиден, но “история” данных стала другой.
Это очень важное ощущение: RAII спасает от утечек, но не спасает от частично применённых изменений.
4. Учебный пример: TaskManager и инварианты
Чтобы не оставаться на уровне «сферический вектор в вакууме», продолжим нашу сквозную идею учебного приложения: маленький консольный менеджер задач. До этого момента мы могли хранить задачи в std::vector, печатать их, находить по id и менять статус.
Добавим модель:
#include <string>
struct Task {
int id = 0;
std::string title;
bool done = false;
};
Теперь сделаем класс-обёртку:
#include <vector>
class TaskManager {
public:
int add_task(const std::string& title);
private:
std::vector<Task> tasks_;
int next_id_ = 1;
};
И давайте сформулируем инвариант, максимально по-человечески: next_id_ всегда должен быть «следующим свободным id», то есть больше любого уже выданного id. Это удобно, потому что тогда add_task всегда даёт уникальный id.
Плохая версия add_task: инвариант ломается при исключении
Сейчас мы специально напишем код, который выглядит разумно, но при исключении оставляет объект в странном состоянии. После этого станет намного легче узнавать такие вещи глазами.
#include <string>
#include <utility>
#include <vector>
class TaskManager {
public:
int add_task_bad(std::string title) {
const int id = next_id_;
++next_id_; // "забронировали" id заранее
tasks_.push_back(Task{ id, std::move(title), false }); // может бросить
return id;
}
private:
std::vector<Task> tasks_;
int next_id_ = 1;
};
Почему push_back может бросить? Например, из-за выделения памяти (очень редко, но возможно), а в более общем случае — из-за исключений при копировании/перемещении полей, если бы у вас там были более сложные типы. Даже базовый сценарий с нехваткой памяти приводит к идее std::bad_alloc как «исключение про память».
Что случится, если push_back бросит? Мы уже увеличили next_id_. В итоге задача не добавлена, но счётчик id уехал вперёд. Кажется мелочью? Иногда да. А иногда вы хотите, чтобы id шли без дырок, или вы используете id как косвенный показатель количества операций. И вот уже логика «поплыла».
Главное: объект жив, tasks_ валиден, утечек нет. Но инвариант «next_id_ = max_id + 1» может нарушиться.
Хорошая версия add_task: сначала опасное, потом фиксация результата
Сейчас мы исправим метод очень простым приёмом: не меняем важные поля состояния до тех пор, пока не прошли все шаги, которые могут бросить. По сути, мы просто меняем порядок строк.
#include <string>
#include <utility>
#include <vector>
class TaskManager {
public:
int add_task(std::string title) {
const int id = next_id_;
tasks_.push_back(Task{ id, std::move(title), false }); // может бросить
++next_id_; // если дошли сюда — значит задача реально добавлена
return id;
}
private:
std::vector<Task> tasks_;
int next_id_ = 1;
};
Теперь если push_back упал исключением, next_id_ не изменился, а значит логика не “уезжает”. Это маленькое изменение — но это и есть стиль мышления, который вам сегодня нужен: исключение может произойти в любой «опасной» точке, и состояние должно оставаться согласованным.
5. Двойные структуры и риск рассинхронизации
До сих пор у нас был один контейнер и одно поле. В реальности часто хочется ускорить поиск: хранить, например, ещё и индекс id -> позиция в векторе. Мы уже проходили unordered_map, так что идея не новая.
Псевдо-дизайн:
- tasks_ — основной список
- index_ — быстрый поиск id
#include <unordered_map>
#include <vector>
class TaskManager {
public:
// ...
private:
std::vector<Task> tasks_;
std::unordered_map<int, std::size_t> index_; // id -> position
int next_id_ = 1;
};
Здесь инвариант становится богаче: для каждого Task в tasks_ должна существовать запись в index_, и она должна указывать на правильную позицию.
И вот тут исключения начинают реально «кусаться»: вы добавили в tasks_, потом добавляете в index_, и на вставке в unordered_map тоже может случиться выделение памяти, а значит — потенциальное исключение. В результате вы получите задачу в tasks_, но без записи в index_. Утечек нет. Но логика сломана: “поиск по id” внезапно перестаёт находить существующую задачу.
На уровне сегодняшней лекции важно не столько написать идеальную реализацию (мы ещё будем обсуждать более системные подходы), сколько научиться видеть опасность: как только у вас несколько структур, которые должны быть согласованы, исключение между обновлениями почти гарантирует рассинхронизацию, если вы не продумали порядок действий.
6. Практические приёмы удержания инвариантов
Сейчас хочется схватиться за try/catch и начать «отменять изменения». Иногда это действительно нужно, но сегодня мы тренируем другой рефлекс: делать так, чтобы отменять было нечего, или чтобы “опасные” операции не ломали смысловое состояние.
Первый и самый дешёвый приём — валидация входных данных до изменения состояния. Если вы должны запретить пустой заголовок задачи, то проверяйте это до любых модификаций:
#include <stdexcept>
#include <string>
void validate_title(const std::string& title) {
if (title.empty()) {
throw std::invalid_argument("empty task title");
}
}
Второй приём — обновлять состояние в правильном порядке: сначала всё, что может бросить, потом то, что фиксирует факт успешной операции. Мы это уже сделали в add_task.
Третий приём — минимизировать «сдвоенное состояние», когда одно и то же знание хранится в двух местах. Чем больше у вас копий истины, тем больше шансов, что исключение оставит одну копию обновлённой, а другую — нет. Иногда лучше пожертвовать скоростью и вычислять что-то на лету, чем хранить лишний “кэш”, который потом сложно поддерживать согласованным.
Четвёртый приём — держать инвариант как мысль, а не как надежду. В учебных проектах очень помогает простая внутренняя проверка:
#include <cassert>
class TaskManager {
public:
void check_invariant() const {
assert(next_id_ >= 1);
// В реальном коде проверок может быть больше.
}
private:
std::vector<Task> tasks_;
int next_id_ = 1;
};
Да, assert — это не «обработка ошибок пользователя». Это ваша сигнализация “если это произошло — я как разработчик хочу узнать немедленно”. Особенно полезно, когда вы только учитесь и хотите быстро поймать момент, где объект становится некорректным.
7. Маленький тест в main
Чтобы закрепить идею, давайте покажем пример кода, где мы вызываем метод, который потенциально может бросить, ловим исключение, но хотим после этого продолжить работу (например, просто вывести ошибку и жить дальше).
#include <exception>
#include <iostream>
#include <string>
int main() {
TaskManager tm;
try {
tm.add_task("Buy milk");
tm.add_task(""); // допустим, вы валидируете и бросаете invalid_argument
} catch (const std::exception& e) {
std::cout << "ERROR: " << e.what() << '\n'; // ERROR: empty task title
}
// Важно, что сюда мы дошли, и tm должен оставаться в нормальном состоянии.
}
Смысл не в том, чтобы “поймать всё и жить счастливо”. Смысл в том, что если мы уже ловим исключение на верхнем уровне и продолжаем работу, то наши объекты должны быть достаточно корректными, чтобы продолжение не превратилось в лотерею.
8. Деструкторы и очень плохие сценарии
Сейчас будет короткая, но важная ремарка. Когда исключение летит вверх, деструкторы вызываются автоматически. Если при этом деструктор сам выбросит исключение наружу — ситуация становится аварийной (программа обычно завершится через механизм аварийного завершения/terminate в контекстах исключений).
На уровне этой лекции вам достаточно запомнить практическое правило: деструктор должен быть максимально “безопасным” и не пытаться сообщать об ошибках исключениями наружу. Деструктор — это уборщик. Уборщик не должен выбегать из здания с криком “У НАС ТУТ ПРОБЛЕМА!”, роняя швабру на пожарную сигнализацию.
9. Типичные ошибки
Ошибка №1: думать, что RAII автоматически делает код “безопасным при исключениях” целиком.
RAII действительно помогает не терять ресурсы и не устраивать утечки при раннем выходе из функции, но он не гарантирует, что ваши поля и контейнеры останутся в согласованном логическом состоянии. Если метод успел поменять половину полей и упал, объект может быть валиден технически, но бессмысленен по данным.
Ошибка №2: менять “ключевые” поля состояния до выполнения потенциально бросающих операций.
Классический пример — заранее увеличивать next_id_, size_, balance_, version_, а потом выполнять push_back или другую операцию, которая может бросить. При исключении вы получаете «факт изменения» без самого изменения. Очень часто это лечится банальной перестановкой строк: сначала опасная операция, потом фиксация результата.
Ошибка №3: хранить одну и ту же информацию в двух местах без железного плана синхронизации.
Как только вы заводите кэш, индекс, “быстрый доступ” или второй контейнер, который должен отражать первый, вы становитесь уязвимыми к исключению между двумя обновлениями. Это не значит, что так делать нельзя, но это значит, что инвариант становится сложнее, а риски — реальнее.
Ошибка №4: ловить исключение “чтобы не падало”, но продолжать работу с объектом, не понимая, в каком он состоянии.
Иногда разработчик пишет catch (...) {} или печатает ошибку и идёт дальше, предполагая, что «ну раз программа не упала — всё норм». Если при этом объект мог остаться частично обновлённым, следующий код начинает работать на минном поле. Правильный вопрос после исключения: “какие свойства объекта я всё ещё могу считать истинными?”
Ошибка №5: отсутствие явного инварианта как идеи.
Если вы не можете одним-двумя предложениями объяснить, что такое “корректный объект” для вашего класса, то вы почти гарантированно не сможете написать методы, которые сохраняют корректность при исключениях. Инвариант — это не бюрократия, а способ не обманывать самого себя.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ