1. Зачем нужны гарантии exception safety
Если вы только начинаете программировать, кажется, что исключения — это просто “способ сообщить об ошибке”. В реальности это ещё и проверка на зрелость вашего дизайна: насколько объект умеет переживать “плохие дни”. Гарантии exception safety — это формальные обещания метода о том, что будет со состоянием после исключения, а не только с ресурсами.
Представьте, что ваш объект — это маленький офис. RAII гарантирует, что после паники сотрудники не оставят включённый утюг и не забудут ключи в двери (ресурсы не текут). Но офис может быть перевёрнут вверх дном: документы раскиданы, половина файлов переименована, а половина — нет. Вот гарантии exception safety и отвечают на вопрос: “после паники у нас хотя бы порядок в документах остался?”
Чтобы закрепить тему на практике, продолжим наше условное учебное приложение: TaskTracker — небольшой менеджер задач в памяти, который хранит задачи, теги и быстрые индексы.
Контракт метода и виды гарантий
Когда вы вызываете метод, вы обычно ждёте две вещи: либо метод делает работу, либо честно сообщает, что не смог. Проблема появляется, когда метод не смог, но успел “слегка поделать” — и объект остался в промежуточном состоянии. Именно здесь формулировка контракта становится важнее “красоты кода”.
Контракт в контексте исключений обычно формулируют так: “если метод выбросил исключение, то какие утверждения о состоянии объекта всё ещё истинны?”. Эти утверждения и называются гарантией exception safety. Важно понимать, что гарантия — это не философия и не мораль, а вещь максимально прикладная: это правила, на которые можно опереться при проектировании остального кода.
Ниже мы будем использовать термины basic guarantee, strong guarantee, no‑throw guarantee. Они часто встречаются в разговорах про стандартную библиотеку и дизайн контейнеров, потому что без них невозможно объяснить, почему одни операции “безопаснее” других.
Таблица гарантий: basic vs strong vs no‑throw
Чтобы не утонуть в определениях, давайте сразу зафиксируем смысл в виде таблицы. Это тот редкий случай, когда таблица реально уменьшает боль.
| Гарантия | Что обещаем при исключении | Что может измениться | “Чем платим” |
|---|---|---|---|
| Basic guarantee | Объект остаётся валидным, инварианты не сломаны, ресурсы не текут | Состояние может частично измениться (но корректно) | Обычно почти ничего: достаточно аккуратно поддерживать инварианты |
| Strong guarantee | “Либо всё получилось, либо состояние не изменилось” | Ничего (относительно состояния “до”) | Часто нужна подготовка во временном состоянии, иногда дороже по времени/памяти |
| No‑throw guarantee | Метод не выпускает исключения наружу | Не важно (но обычно подразумевают корректность) | Нужны ограничения на то, что метод делает, или перехват/обработка внутри |
Небольшой факт для “взрослости” темы: даже в обсуждениях стандартной библиотеки встречаются формулировки вида “противоречия спецификации без no‑throw guarantee у swap”, то есть гарантия “не бросает” влияет на требования к операциям и их корректность.
Теперь разберём каждую гарантию отдельно — простыми словами и на коротких примерах.
2. Basic guarantee: объект живой, но мог “переехать мебель”
Basic guarantee — это минимальный разумный уровень в мире исключений. Он говорит: “если операция упала с исключением, объект остаётся пригодным к дальнейшему использованию и разрушению, инварианты соблюдены, но часть состояния могла измениться”. Звучит скромно, но на практике это огромная победа над хаосом.
Ключевое слово здесь — валидный. Валидный объект — это не обязательно объект “как раньше”. Это объект, который не сломан: его методы можно вызывать дальше, его можно корректно удалить (деструктор отработает), и он не содержит внутреннего противоречия.
Пример без гарантии: ломаем инвариант
Сделаем хранилище задач, у которого есть инвариант: index_by_id_ должен соответствовать tasks_. Если задача есть в tasks_, то по её id мы должны найти её индекс.
#include <string>
#include <unordered_map>
#include <vector>
struct Task {
int id;
std::string title;
};
class TaskStorage {
public:
void add_task_no_guarantee(int id, std::string title) {
tasks_.push_back(Task{id, std::move(title)}); // может бросить
index_by_id_.emplace(id, tasks_.size() - 1); // может бросить
}
private:
std::vector<Task> tasks_;
std::unordered_map<int, std::size_t> index_by_id_;
};
Здесь проблема не в том, что “может бросить”. Проблема в том, что если emplace бросит исключение, tasks_ уже изменён, а index_by_id_ — нет. Инвариант сломан. Это даже не basic guarantee — это “гарантия: удачи”.
Пример с basic guarantee: откатили то, что успели сделать
Basic guarantee требует, чтобы после исключения объект остался валиден. Значит, если мы изменили одну часть состояния и не смогли изменить вторую, нам нужно вернуть всё в согласованное состояние.
#include <string>
#include <unordered_map>
#include <vector>
struct Task {
int id;
std::string title;
};
class TaskStorage {
public:
void add_task_basic(int id, std::string title) {
tasks_.push_back(Task{id, std::move(title)}); // шаг 1
try {
index_by_id_.emplace(id, tasks_.size() - 1); // шаг 2
} catch (...) {
tasks_.pop_back(); // откат (не бросает)
throw; // проброс дальше
}
}
private:
std::vector<Task> tasks_;
std::unordered_map<int, std::size_t> index_by_id_;
};
Обратите внимание на важную “учительскую” мысль: мы не “лечим” исключение. Мы просто делаем так, чтобы объект не остался поломанным. Это и есть базовая исключительная безопасность: не скрывать проблему, но сохранить корректность состояния.
4. Strong guarantee: “либо всё, либо ничего”
Strong guarantee звучит как мечта перфекциониста: “если операция не завершилась, состояние объекта ровно такое, как было до вызова”. Да, в реальном мире это достижимо не всегда бесплатно. Но это очень мощная гарантия, потому что вызывающий код может мыслить проще: “если исключение — значит, как будто ничего и не начинали”.
Когда strong guarantee полезен? Когда “полу‑результат” — логически опасен. Например, вы обновляете сразу несколько связанных структур, и если обновилась только половина, остальная программа потом начинает принимать неправильные решения.
Сильная гарантия на маленьком примере: сначала проверили, потом меняем
Иногда strong guarantee достигается удивительно просто: все проверки делаем до изменения состояния.
#include <stdexcept>
#include <string>
class UserName {
public:
void set(std::string n) {
if (n.empty()) {
throw std::invalid_argument("empty name");
}
name_ = std::move(n); // если бы бросило (редко), старое имя должно сохраниться
}
const std::string& get() const { return name_; }
private:
std::string name_{"unknown"};
};
Здесь мы добились очень важного эффекта: плохой ввод не приводит к изменениям. Это уже сильнее, чем basic, по крайней мере в части валидации входа.
Strong guarantee на “двойном состоянии”: теги + счётчик
Добавим в наше TaskStorage ещё один инвариант. Пусть у нас есть tag_usage_: сколько раз каждый тег встречается во всех задачах. Инвариант: сумма/счётчики должны соответствовать реальному содержимому тегов.
Плохая реализация (опять “без гарантии”):
#include <string>
#include <unordered_map>
#include <vector>
struct Task {
int id;
std::vector<std::string> tags;
};
class TaskStorage {
public:
void add_tag_no_guarantee(std::size_t i, std::string tag) {
tasks_[i].tags.push_back(tag); // может бросить
tag_usage_[tag] += 1; // может бросить (вставка/rehash)
}
private:
std::vector<Task> tasks_;
std::unordered_map<std::string, int> tag_usage_;
};
Если tag_usage_ бросит исключение после успешного push_back, у нас тег есть, а счётчик нет. Инвариант сломан.
А теперь версия, которая делает минимум логики для strong‑поведения: если второй шаг не удался — откатываем первый. Важно: откат должен быть таким, который сам по себе “не падает”, иначе мы устроим “исключение при обработке исключения” (а это уже отдельный круг ада).
#include <string>
#include <unordered_map>
#include <vector>
struct Task {
std::vector<std::string> tags;
};
class TaskStorage {
public:
void add_tag_strong(std::size_t i, const std::string& tag) {
tasks_[i].tags.push_back(tag); // шаг 1
try {
tag_usage_[tag] += 1; // шаг 2
} catch (...) {
tasks_[i].tags.pop_back(); // откат шага 1
throw;
}
}
private:
std::vector<Task> tasks_;
std::unordered_map<std::string, int> tag_usage_;
};
Формально мы добились “либо добавили тег и увеличили счётчик, либо не сделали ничего”. Это strong guarantee для этой операции относительно нашего инварианта.
И да, это уже похоже на commit/rollback. В следующей лекции (278) мы разберём более системный и масштабируемый подход (особенно когда шагов больше двух), но сейчас нам важна именно логика гарантий: что обещаем.
5. No‑throw guarantee: “не выпускаю исключения наружу”
No‑throw guarantee — это не “самая сильная гарантия”, а другая ось. Она отвечает не на вопрос “какое состояние останется”, а на вопрос “вылетит ли исключение за границу функции”. Иногда это жизненно важно: например, в деструкторах (выпускать исключения наружу там почти всегда нельзя), в функциях освобождения ресурсов, в некоторых низкоуровневых операциях.
Психологически no‑throw очень коварен: новичкам хочется “пусть всё будет no‑throw, чтобы не думать”. Но реальность такая: если операция делает что-то, что может не получиться (аллоцировать память, прочитать файл, распарсить число), то обещать “я не брошу” можно только если вы внутри функции готовы обработать ошибку иначе.
Естественный кандидат на no‑throw: простые запросы состояния
Геттеры и проверки состояния обычно легко сделать такими, что они не бросают исключений, потому что не делают ничего опасного.
#include <vector>
class TaskStorage {
public:
std::size_t task_count() const noexcept { // подробнее про noexcept будет позже
return tasks_.size();
}
private:
std::vector<int> tasks_;
};
Здесь нет выделения памяти, нет сложной логики, нет парсинга. Поэтому это нормальная цель: “не бросать”.
Неестественный кандидат на no‑throw: формирование строки
Вот классическая ловушка: хочется сделать красивый to_string() и обещать “не бросаю”, но внутри создаются строки, конкатенации, форматирование — всё это потенциально требует памяти.
В таком месте обычно честнее либо не обещать no‑throw, либо возвращать результат, который может не содержать значения (но это уже другая стратегия ошибок, не про exception safety напрямую).
6. Как выбрать гарантию для методов: практический взгляд
В учебниках легко написать “всегда делайте strong guarantee”. В реальном коде вы быстро поймёте, что это иногда дорого или просто неудобно. Поэтому полезнее мыслить так: “какая гарантия нужна вызывающему коду, чтобы программа оставалась простой и надёжной?”
Для нашего TaskTracker можно договориться о такой политике (не как список правил на камне, а как здравый смысл):
| Метод | Что делает | Какая гарантия обычно разумна |
|---|---|---|
|
просто читает число | no‑throw (естественно) |
|
ищет задачу | no‑throw или basic (если не меняет состояние) |
|
добавляет задачу + индекс | хотя бы basic, часто удобно делать strong |
|
меняет задачу + счётчики | лучше strong, иначе легко словить рассинхрон |
|
парсит ввод/файл | может бросать, гарантия зависит от дизайна (часто strong через “собрать временно”) |
И здесь есть важный момент: strong guarantee часто нужна не “ради красоты”, а ради совместимости с инвариантами. Если ваш объект внутри держит несколько связанных структур, то partial update превращается в мину. Вы можете формально сказать “basic guarantee”, но если при исключении структура становится неконсистентной — это не basic, это “никакой”.
7. Мини‑демонстрация: как увидеть гарантию руками
Когда читаешь определения, кажется, что всё понятно. Но мозг по‑настоящему запоминает, когда вы видите эффект: “вот тут исключение — и объект всё ещё нормальный” или “вот тут исключение — и объект превращается в тыкву”. Для этого полезно уметь искусственно провоцировать исключения в середине операции.
Самый простой учебный трюк — функция, которая “иногда бросает”. В продакшене так делать не надо, но для демонстрации — отлично.
#include <stdexcept>
void fail_if(bool condition) {
if (condition) {
throw std::runtime_error("simulated failure");
}
}
И теперь внутри операции вы можете вставлять fail_if(true) в нужное место и смотреть: сохранились ли инварианты, можно ли дальше работать, не развалился ли объект.
Например, “поддельно” ломаем добавление индекса:
#include <string>
#include <unordered_map>
#include <vector>
struct Task { int id; std::string title; };
class TaskStorage {
public:
void add_task_demo(int id, std::string title, bool fail_on_index) {
tasks_.push_back(Task{id, std::move(title)});
fail_if(fail_on_index); // имитируем сбой “между шагами”
index_by_id_.emplace(id, tasks_.size() - 1);
}
private:
std::vector<Task> tasks_;
std::unordered_map<int, std::size_t> index_by_id_;
};
Если у вас нет отката, то после fail_if(true) объект уже изменён наполовину. Это и есть тот самый момент, когда вы говорите: “А какую гарантию я вообще хотел дать? И даю ли я её?”
8. Типичные ошибки при выборе гарантий exception safety
Ошибка №1: думать, что RAII автоматически даёт strong guarantee для всего.
RAII действительно почти магически защищает ресурсы, но логическое состояние объекта RAII не чинит. Если вы поменяли три поля, а на четвёртом упали — утечки может и не быть, но объект может оказаться “полу‑обновлённым”. Гарантия — это про состояние, а не про память.
Ошибка №2: заявлять basic guarantee, но ломать инварианты при исключении.
Basic guarantee требует валидности объекта. Если ваш объект хранит связанные структуры (например, vector + unordered_map индекс), то partial update делает объект невалидным. В таком случае вы не “дали basic”, вы дали “ничего”. Нужно либо откатывать изменения, либо менять порядок действий так, чтобы инвариант не разрушался.
Ошибка №3: путать strong guarantee и no‑throw guarantee.
Strong guarantee говорит “состояние не изменится, если операция упала”, но операция при этом вполне может бросать исключение. No‑throw говорит “исключение наружу не выйдет”, но это не означает, что состояние не меняется или что операция всегда успешна. Это разные обещания, и путаница здесь приводит к очень странным интерфейсам.
Ошибка №4: ставить no‑throw как цель “по умолчанию”.
Иногда хочется объявить “у нас всё no‑throw, и точка”. Но если внутри делается выделение памяти, парсинг, работа с I/O, то либо вы начнёте скрывать ошибки (и потом ловить загадочные баги), либо будете вынуждены строить альтернативную систему ошибок. No‑throw — хороший инструмент, но только там, где он честный.
Ошибка №5: делать откат “на глазок”, забывая, что откат тоже должен быть безопасным.
Если вы в catch пытаетесь откатить изменения операциями, которые сами могут бросить, вы рискуете получить второе исключение в процессе обработки первого. Поэтому откат должен быть максимально простым и надёжным: pop_back(), возврат счётчика на 1, swap с уже готовым состоянием и так далее. Более системно эту тему мы продолжим в следующей лекции, где разберём commit/rollback и swap как шаг коммита.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ