1. Основна ідея: інваріанти та раннє падіння
Якщо дивитися на assert очима новачка, він може здаватися дивним: «Навіщо мені конструкція, яка навмисно аварійно завершує програму? Я ж хочу, щоб вона працювала». І це слушне відчуття: у користувацькому сценарії ми й справді намагаємося діяти обережно. Але програміст має ще одне завдання: ловити власні помилки під час розроблення якомога раніше і якомога ближче до місця, де було порушено власні правила.
Санітайзери чудово виявляють цілі класи проблем, але читати ваші думки вони не вміють. Наприклад, санітайзер може сказати «out of bounds», але не скаже: «Ви ж обіцяли собі, що id задачі завжди додатний, а він раптом став -17». Ось тут assert працює як маленький «датчик здорового глузду»: ви явно фіксуєте в коді очікування, яке має бути істинним, якщо логіка програми коректна.
Корисна метафора: санітайзер — це металодетектор на вході в аеропорт, а assert — це чекліст пілота перед зльотом. Металодетектор виявляє багато небезпечного, але чекліст перевіряє те, що важливо саме для цього літака саме зараз.
Інваріант і помилка користувача
Слово «інваріант» звучить так, ніби його вигадали, щоб студенти сумували. Насправді ідея дуже проста: інваріант — це умова, яка «в нормі» завжди істинна у певній точці програми. Наприклад: «індекс завжди в межах вектора», «дільник не дорівнює нулю», «довжина рядка невідʼємна» (так, звучить смішно, але саме такі речі ми інколи забуваємо).
Ключовий момент: інваріант — це зазвичай наша внутрішня обіцянка, а не договір із користувачем. Користувач може ввести нісенітницю: порожній рядок, літери замість числа, відʼємний id — і це не «помилка програми», а звична реальність. Такі ситуації ми обробляємо через if: показуємо повідомлення про помилку й повертаємо «не вдалося».
А якщо всередині програми ми самі обчислили індекс і несподівано отримали i == v.size(), або самі згенерували id і він раптом став 0, — це вже не «помилка користувача». Це означає, що наша логіка дала збій, і продовжувати виконання часто небезпечно: можна натрапити на UB. Саме такі випадки й покриває assert.
2. Як працює assert у C++
Базовий синтаксис: як увімкнути і як виглядає падіння
assert у C++ — це макрос, який підключається через заголовок <cassert>. Макрос — важливе слово: це не функція, а конструкція, яку розгортає препроцесор. Тому у assert є особливість: за певних налаштувань збирання він може перетворитися на «нічого», тобто умова навіть не обчислюватиметься (про це трохи згодом).
Мінімальний приклад:
#include <cassert>
int main() {
int x = 10;
assert(x > 0); // ок, умова істинна
}
Якщо умова хибна, програма аварійно завершиться. Зазвичай ви побачите повідомлення на кшталт «assertion failed» і координати помилки (це залежить від платформи та збирання). Важливо: assert призначений не для «гарного UX», а для розробника, який хоче одразу зрозуміти: «ми приїхали в неможливий стан».
Історичний штрих зі світу стандартів: навіть довкола assert тривають обговорення того, як зробити діагностику дружнішою. Наприклад, у документах WG21 є окремі пункти про зміни, повʼязані з тим, щоб зробити assert() більш дружнім для користувача в C і C++.
Куди ставити assert
Майже всі корисні assert у реальному коді стоять у двох місцях:
- перед потенційно небезпечною операцією, яка за неправильних даних може призвести до UB або безглуздого результату;
- після зміни стану, коли ми хочемо переконатися, що обʼєкт або контейнер залишився в коректному стані.
Чому це важливо? Тому що assert має виявляти проблему якомога ближче до першопричини. Якщо поставити його надто пізно, ви отримаєте падіння в неочікуваному місці, хоча реальна помилка могла статися на 20 рядків раніше. А якщо поставити його надто рано й надто загально, він спрацьовуватиме то доречно, то просто через розмитість умови.
Приклад «до небезпечного рядка» — ділення:
#include <cassert>
int Divide(int a, int b) {
assert(b != 0);
return a / b;
}
Приклад «до небезпечного рядка» — індексування:
#include <cassert>
#include <vector>
int GetAt(const std::vector<int>& v, std::size_t i) {
assert(i < v.size());
return v[i]; // якщо умову порушено, краще впасти ДО цього рядка
}
Приклад «після зміни стану»: ми додали елемент і хочемо переконатися, що контейнер не став порожнім:
#include <cassert>
#include <vector>
void AddOne(std::vector<int>& v, int x) {
v.push_back(x);
assert(!v.empty());
}
NDEBUG: чому assert може «зникнути»
Ось найважливіший підводний камінь: assert часто працює так, що у Debug-збиранні він активний, а в Release-збиранні може бути вимкнений. Вимкнення зазвичай повʼязане з макросом NDEBUG: якщо він визначений, assert(...) перетворюється на порожню конструкцію.
Практичний висновок: не можна будувати логіку програми так, щоб без assert вона поводилася інакше. assert — це не «якщо що, зроби важливу дію», а «якщо що, перевір умову». І все.
Ось приклад поганого коду — assert із побічним ефектом:
#include <cassert>
int main() {
int x = 0;
assert(++x == 1); // погано: змінюємо x всередині assert
// у Release це може перетворитися на "нічого",
// і тоді x так і залишиться 0
}
А ось хороший варіант: спочатку виконуємо дію, а потім перевіряємо:
#include <cassert>
int main() {
int x = 0;
++x;
assert(x == 1); // добре: перевірка не змінює стан
}
Запамʼятайте просте правило: вираз усередині assert(...) має бути максимально «чистим» — без зміни змінних і без викликів функцій, які щось змінюють.
Як зробити падіння зрозумілішим: повідомлення в assert
У assert є приємна властивість: ви можете зробити умову трохи більш «балакучою», щоб під час падіння одразу було зрозуміло, який контракт порушено. В ідеалі програміст, побачивши падіння assert, має за 10 секунд зрозуміти, що пішло не так, а не перечитувати весь проєкт із початку.
Класичний трюк (без побічних ефектів) — додати рядковий літерал через &&. Якщо умова хибна, увесь вираз також буде хибним, і assert спрацює:
#include <cassert>
#include <vector>
int GetAt(const std::vector<int>& v, std::size_t i) {
assert(i < v.size() && "індекс має бути в межах вектора");
return v[i];
}
Чому це працює? Тому що "text" — це вказівник на рядковий літерал, він «істинний» у логічному сенсі, і в нормі вираз зводиться до перевірки i < v.size(). А текст допомагає вам чи колезі швидше зрозуміти зміст інваріанта.
Важливо: це саме підказка для розробника. Користувачу ми такі повідомлення не показуємо (і взагалі assert не про користувача).
3. Приклад: додаємо assert у TaskKeeper
Щоб assert не залишився «академічним звіром», давайте вбудуємо його в навчальний консольний застосунок. Припустімо, у нас є простий менеджер задач: зберігаємо список задач (std::vector), у кожної задачі є id, title і прапорець done. Застосунок уміє додавати задачу, позначати її як виконану та друкувати список.
Модель
#include <string>
struct Task {
int id = 0;
std::string title;
bool done = false;
};
Генерація id
Тут інваріант простий: id завжди додатний, а nextId завжди більший за нуль.
#include <cassert>
int GenerateId(int& nextId) {
assert(nextId > 0 && "nextId має залишатися додатним");
int id = nextId;
++nextId;
assert(id > 0);
return id;
}
Зверніть увагу на підхід: ми не «лікуємо» ситуацію, коли nextId <= 0. Ми вважаємо її логічно неможливою за коректного коду. Отже, якщо це сталося, краще впасти й розібратися.
Додавання задачі
Тут є дві категорії перевірок:
- «користувацька»: заголовок може бути порожнім, і це нормальна ситуація: ми просто не додамо задачу;
- «наша внутрішня»: якщо ми додали задачу, її id має бути валідним, а вектор після додавання не повинен раптово стати меншим.
#include <cassert>
#include <string>
#include <vector>
bool AddTask(std::vector<Task>& tasks, int& nextId, const std::string& title) {
if (title.empty()) return false; // помилка користувача/вводу
const std::size_t oldSize = tasks.size();
Task t;
t.id = GenerateId(nextId);
t.title = title;
tasks.push_back(t);
assert(tasks.size() == oldSize + 1);
assert(tasks.back().id > 0);
return true;
}
Пошук за id
Тут зручно повертати індекс. Ми вже знайомі зі std::optional із попередніх тем, і це саме той випадок: «знайшли / не знайшли» — нормальний результат, а не аварія.
Але й тут можна поставити assert на внутрішній контракт: якщо ми повертаємо індекс, він точно перебуває в межах tasks.size().
#include <cassert>
#include <optional>
#include <vector>
std::optional<std::size_t> FindTaskIndexById(const std::vector<Task>& tasks, int id) {
if (id <= 0) return std::nullopt; // користувач міг ввести нісенітницю
for (std::size_t i = 0; i < tasks.size(); ++i) {
if (tasks[i].id == id) {
assert(i < tasks.size());
return i;
}
}
return std::nullopt;
}
Позначити виконаною
Тут типова пастка: ми знайшли індекс, а потім звертаємося до tasks[index]. Якщо раптом індекс неправильний, це прямий кандидат на UB. Тому ми ставимо assert просто перед доступом, навіть якщо «логічно індекс уже перевірено». Це як другий ремінь безпеки: він майже ніколи не спрацьовує, але коли таки спрацьовує, ви раді, що він був.
#include <cassert>
#include <optional>
#include <vector>
bool MarkDone(std::vector<Task>& tasks, int id) {
const auto idx = FindTaskIndexById(tasks, id);
if (!idx) return false;
assert(*idx < tasks.size());
tasks[*idx].done = true;
return true;
}
Мінімальний main
Без ускладненого введення — тут ми тренуємо саме assert.
#include <iostream>
#include <vector>
int main() {
std::vector<Task> tasks;
int nextId = 1;
AddTask(tasks, nextId, "Прочитати про assert");
AddTask(tasks, nextId, "Виправити UB до того, як він виправить вас");
MarkDone(tasks, 1);
for (const auto& t : tasks) {
std::cout << t.id << ". " << t.title
<< (t.done ? " [готово]" : " [зробити]") << '\n';
// 1. Прочитати про assert [готово]
// 2. Виправити UB до того, як він виправить вас [зробити]
}
}
Сенс цього фрагмента не в тому, щоб створити ідеальний менеджер задач, а в тому, щоб побачити, як assert фіксує наші внутрішні обіцянки: «id додатний», «індекс у межах», «після push_back розмір виріс на 1».
4. assert у налагодженні та в поєднанні із санітайзерами
Коли assert спрацьовує, часто здається, що «програма просто впала». Насправді це доволі інформативний збій — на відміну від UB, який може проявитися будь-де й без жодних підказок. А якщо ви ввімкнули режим діагностики (Debug і/або із санітайзером), то поруч зазвичай будуть і координати, і можливість подивитися стек викликів.
Корисно тримати в голові просту схему того, як ми відловлюємо помилки шарами:
flowchart TD
A[Логічна помилка: порушено інваріант] --> B[assert спрацьовує одразу]
A --> C[якщо assert немає: рухаємося далі]
C --> D[може статися UB]
D --> E[санітайзер ловить частину UB]
D --> F[без санітайзера: дивні симптоми]
Практичне правило: якщо у вас є місце, де потенційно може статися UB (індексування, ділення, зсув), то assert перед операцією часто перетворює «дивну проблему за 5 хвилин» на «зрозумілий креш просто тут».
5. Типові помилки під час використання assert
Помилка № 1: використовувати assert для перевірки користувацького введення.
Коли користувач вводить неправильний id або порожній рядок, це не «неможливий стан», а нормальна гілка програми. Якщо ви поставите assert(id > 0) на вході команди, програма падатиме від будь-якого неправильного введення. Це не захист, а спосіб посваритися з користувачем. Для таких випадків потрібен звичайний if, акуратне повідомлення й повернення «не вдалося».
Помилка № 2: розраховувати на assert як на обовʼязкову частину логіки.
Через те що assert може бути вимкнений через NDEBUG, не можна писати код так, щоб усередині assert були важливі дії. Будь-який побічний ефект в умові (++x, запис у контейнер, зміна прапорця) перетворює ваше Release-збирання на версію програми з іншою логікою. А потім ви довго пояснюватимете, чому «у Debug усе працює».
Помилка № 3: ставити assert після небезпечної операції.
Інколи трапляється конструкція: «спочатку читаємо v[i], потім перевіряємо i < v.size()». Це вже запізно: якщо індекс неправильний, UB міг статися на рядку читання, а перевірка нижче не врятує. assert має стояти перед потенційно небезпечним рядком, інакше це просто декоративна табличка «Обережно, тут був обрив».
Помилка № 4: робити assert надто загальними й беззмістовними.
assert(true) і assert(x == x) не фіксують жодної реальної ідеї. Хороший assert має виражати конкретний контракт: «індекс у межах», «розмір збільшився», «id додатний», «вказівник не null». Якщо ви не можете пояснити словами, навіщо саме цей assert тут стоїть, найімовірніше, він вам не допоможе.
Помилка № 5: перетворювати код на «мінне поле assertʼів».
assert — потужна штука, але якщо вставляти його кожні два рядки без чіткої потреби, код стає нечитабельним і нервовим. Краще ставити assert у місцях, де є чіткий контракт: на межах функцій, перед небезпечними операціями, після важливих змін стану. Тоді кожен assert буде як добра дорожня розмітка, а не як графіті на кожній стіні.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ