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_;
};
Зверніть увагу на важливу навчальну думку: ми не «лікуємо» виняток. Ми лише не дозволяємо обʼєкту залишитися зламаним. У цьому й полягає базова виняткова безпека: не приховувати проблему, але зберегти коректність стану.
3. 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("порожнє імʼя");
}
name_ = std::move(n); // якщо тут виникне виняток (рідко), старе імʼя має зберегтися
}
const std::string& get() const { return name_; }
private:
std::string name_{"unknown"};
};
Тут ми отримали дуже важливий ефект: некоректне введення не призводить до змін. Це вже сильніше, ніж basic guarantee, принаймні на етапі валідації введення.
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) ми розберемо системніший і масштабніший підхід, особливо коли кроків більше ніж два. Але зараз для нас важлива сама логіка гарантій: що саме ми обіцяємо.
4. 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 напряму.
5. Як вибрати гарантію для методів: практичний погляд
У підручниках легко написати: «завжди робіть strong guarantee». У реальному коді ви швидко зрозумієте, що це іноді дорого або просто незручно. Тому корисніше мислити так: «Яка гарантія потрібна коду, що викликає метод, щоб програма залишалася простою й надійною?»
Для нашого TaskTracker можна домовитися про таку політику — не як про список правил, висічених у камені, а як про прояв здорового глузду:
| Метод | Що робить | Яка гарантія зазвичай розумна |
|---|---|---|
|
просто читає число | no‑throw (природно) |
|
шукає завдання | no‑throw або basic, якщо не змінює стан |
|
додає завдання + індекс | принаймні basic, а часто зручно робити strong |
|
змінює завдання + лічильники | краще strong, інакше легко впіймати розсинхронізацію |
|
парсить введення або файл | може кидати; гарантія залежить від дизайну, але часто зручно робити strong через «зібрати тимчасово» |
І тут є важливий момент: strong guarantee часто потрібна не «заради краси», а заради сумісності з інваріантами. Якщо ваш обʼєкт усередині тримає кілька повʼязаних структур, то часткове оновлення перетворюється на міну. Ви можете формально сказати «basic guarantee», але якщо під час винятку структура стає неузгодженою, то це вже не basic, а відсутність будь-якої гарантії.
6. Міні‑демонстрація: як побачити гарантію на практиці
Коли читаєш визначення, здається, що все зрозуміло. Але найкраще це запамʼятовується тоді, коли ви бачите ефект: «ось тут виняток — і обʼєкт усе ще нормальний» або «ось тут виняток — і обʼєкт перетворюється на гарбуз». Для цього корисно вміти штучно провокувати винятки посеред операції.
Найпростіший навчальний трюк — функція, яка «іноді кидає». У робочому коді так робити не треба, але для демонстрації це чудовий інструмент.
#include <stdexcept>
void fail_if(bool condition) {
if (condition) {
throw std::runtime_error("імітація збою");
}
}
І тепер усередині операції ви можете вставляти 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) обʼєкт уже змінено наполовину. Саме в цей момент і варто поставити собі запитання: «Яку гарантію я взагалі хотів дати? І чи справді я її даю?»
7. Типові помилки під час вибору гарантій exception safety
Помилка № 1: думати, що RAII автоматично дає strong guarantee для всього.
RAII справді майже магічно захищає ресурси, але логічний стан обʼєкта він не виправляє. Якщо ви змінили три поля, а на четвертому впали, витоку може й не бути, але обʼєкт може виявитися «напівоновленим». Гарантія — це про стан, а не про памʼять.
Помилка № 2: заявляти basic guarantee, але ламати інваріанти у разі винятку.
Basic guarantee вимагає валідності обʼєкта. Якщо ваш обʼєкт зберігає повʼязані структури, наприклад vector + unordered_map як індекс, то часткове оновлення робить його невалідним. У такому разі ви не «дали basic», а фактично не дали нічого. Треба або відкочувати зміни, або змінювати порядок дій так, щоб інваріант не руйнувався.
Помилка № 3: плутати strong guarantee і no‑throw guarantee.
Strong guarantee каже: «Стан не зміниться, якщо операція завершилася винятком», але сама операція при цьому цілком може кидати винятки. No‑throw guarantee каже: «Виняток назовні не вийде», але це не означає, що стан не змінюється або що операція завжди успішна. Це різні обіцянки, і плутанина тут призводить до дуже дивних інтерфейсів.
Помилка № 4: ставити no‑throw як ціль «за замовчуванням».
Іноді хочеться оголосити: «У нас усе no‑throw — і крапка». Але якщо всередині є виділення памʼяті, парсинг або робота з I/O, то ви або почнете приховувати помилки й потім ловити загадкові баги, або будете змушені будувати альтернативну систему помилок. No‑throw — хороший інструмент, але лише там, де він чесний.
Помилка № 5: робити відкат «на око», забуваючи, що він теж має бути безпечним.
Якщо ви в catch намагаєтеся відкотити зміни операціями, які самі можуть кинути, ви ризикуєте отримати другий виняток у процесі обробки першого. Тому відкат має бути максимально простим і надійним: pop_back(), зменшення лічильника на 1, swap з уже готовим станом і так далі. Системніше цю тему ми продовжимо в наступній лекції, де розберемо commit/rollback і swap як крок коміту.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ