1. Навіщо потрібні санітайзери і як вони працюють
Якщо ви пишете програму і вона «нібито запускається», мозок автоматично ставить галочку: «готово». Я вас чудово розумію: мозку подобається закривати вкладки. Проблема в тому, що деякі помилки в C++ не зобовʼязані проявлятися одразу. Вони можуть датися взнаки лише за іншого рівня оптимізації, на іншому компʼютері, після невеликого рефакторингу або взагалі лише тоді, коли ви вже показали демо замовнику. І зазвичай це стається в найнезручніший момент.
Санітайзери потрібні, щоб програма падала раніше й зрозуміліше, замість того щоб тихо псувати памʼять або обчислювати безглузді значення. Їх зручно сприймати як «режим підвищеної чесності»: компілятор додає до вашої програми додаткові перевірки, а в разі порушення правил друкує докладний звіт.
Важливо памʼятати: санітайзер — це не «ще одна бібліотека», яку ми підключаємо через #include <sanitizer>. Це саме режим збирання, коли компілятор інструментує машинний код, тобто додає перевірки.
Інструментування: чому це інший бінарник
Коли ми кажемо «зібрати із санітайзером», то маємо на увазі, що компілятор вставляє додаткові інструкції: перевірки меж, перевірки коректності операцій, «охоронні зони» навколо масивів та інші хитрощі. У результаті маємо виконуваний файл, який працює повільніше, зате дуже уважно стежить за тим, що відбувається.
Зручно уявити це як дві різні реальності того самого вихідного коду:
flowchart LR
A[main.cpp] --> B[Звичайне збирання]
A --> C[Збирання із санітайзером]
B --> D[app]
C --> E[app_sanitized]
D --> F[Запуск: швидко, але без «детектора брехні»]
E --> G[Запуск: повільніше, зате виявляє цілі класи помилок]
Тобто ви не «вмикаєте ASan на хвилинку всередині програми». Ви створюєте інше збирання (часто навіть окремий каталог build-asan/), запускаєте його й дивитеся, що воно скаже.
2. ASan і UBSan: ролі й зона відповідальності
Сьогодні нас цікавлять два санітайзери, які найчастіше дають найбільше користі початківцю. І не лише початківцю.
ASan: AddressSanitizer
Він спеціалізується на помилках доступу до памʼяті. На цьому етапі для нас це насамперед «вийшли за межі масиву, буфера чи вектора» і «прочитали або записали не туди».
UBSan: UndefinedBehaviorSanitizer
Він діагностує деякі види Undefined Behavior, повʼязані не стільки з «памʼяттю як контейнером», скільки з «операціями мови»: діленням на нуль, переповненням типу зі знаком, некоректними зсувами та подібними речами.
Щоб не перетворювати це на список «пʼятдесят відтінків UB», зробімо компактну таблицю — своєрідну мапу місцевості:
| Інструмент | Що ловить найкраще | Найтиповіші «влучання» на нашому рівні |
|---|---|---|
|
Помилки доступу до памʼяті | вихід за межі масиву a[i], помилки індексації vector при operator[], запис за кінець буфера |
|
Деякі види UB у виразах | ділення на 0, signed overflow, некоректний бітовий зсув |
Важлива думка: ASan і UBSan не конкурують, а доповнюють одне одного. Часто ви вмикаєте один із них, коли маєте конкретну підозру, а інколи — обидва, щоб не гадати.
3. ASan: які помилки доступу до памʼяті ловить
Коли кажуть «помилки памʼяті», початківці часто думають про new/delete і страшні вказівники. Але найчастіший шлях до проблем із памʼяттю для тих, хто тільки починає, — це зовсім не new/delete, а банальний неправильний індекс.
Тут ASan справді виручає: він перетворює «випадкове псування даних» на чіткий сигнал: «ви вийшли за межі, ось рядок, ось стек».
Вихід за межі C-масиву: stack-buffer-overflow
Почнімо з класики: масиву фіксованого розміру.
#include <iostream>
int main() {
int a[3] = {10, 20, 30};
#if 0
std::cout << a[3] << '\n'; // UB: індекс 3 поза діапазоном 0..2
#endif
std::cout << a[0] << '\n'; // 10
}
Чому це важливо саме в контексті ASan? Без санітайзера такий код може «просто вивести сміття», може «випадково вивести 0», а може «впасти не тут». З ASan ви, найімовірніше, отримаєте зрозуміле повідомлення на кшталт stack-buffer-overflow і місце, де це сталося.
Вихід за межі std::vector при operator[]
std::vector — контейнер зручний, але його operator[] не перевіряє межі. Це свідомий дизайн: швидше, але небезпечніше.
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{1, 2, 3};
std::size_t i = v.size(); // i == 3
#if 0
std::cout << v[i] << '\n'; // UB: v[3] — це «за кінцем»
#endif
std::cout << v[2] << '\n'; // 3
}
Ця помилка настільки типова, що її варто запамʼятати як рефлекс: size() — це кількість елементів, а останній індекс — size() - 1 (якщо контейнер не порожній).
Запис за межі: зазвичай небезпечніший за читання
Читання «за межею» вже погане. Але запис «за межею» зазвичай гірший: ви псуєте памʼять, і програма може ще довго працювати, доки не впаде зовсім в іншому місці.
#include <vector>
int main() {
std::vector<int> v{10, 20, 30};
#if 0
v[3] = 99; // UB: пишемо за кінець
#endif
return 0;
}
Тут ASan особливо корисний, бо часто ловить такі речі «на місці злочину», а не за годину, коли у вас зламалося сортування.
Що ASan не гарантує зловити
ASan ловить цілі класи проблем, але він не чарівник і не психотерапевт для вашої бізнес-логіки. Якщо ви переплутали формулу й рахуєте середнє як sum / (n + 1) — це не до ASan. Якщо ви неправильно обробили введення, це теж не до ASan.
Саме тому санітайзер — не «заміна уважності», а підсилювач: він допомагає ловити ті помилки, які інакше виглядали б як хаос.
4. UBSan: які види UB діагностує
UBSan корисний там, де код компілюється, виглядає «майже нормально», але порушує жорсткі правила мови. Підступність таких місць у тому, що компілятор має право оптимізувати їх так, що логіка почне поводитися дивно.
До речі, навіть стандартна бібліотека і сам стандарт постійно застерігають: «ось тут поведінка не визначена, так робити не можна», особливо в усьому, що стосується арифметики та ітераторів.
Ділення на нуль у цілих числах
Навіть якщо ви «в математиці бачили такий вираз», у цілочисельній арифметиці C++ ділення на 0 — UB.
#include <iostream>
int main() {
int a = 10;
int b = 0;
#if 0
int c = a / b; // UB: ділення на 0
std::cout << c << '\n';
#endif
std::cout << a << '\n'; // 10
}
UBSan зазвичай повідомляє щось на кшталт runtime error: division by zero. І це дуже зручно: ви перестаєте гадати, «чому воно впало».
Signed overflow: переповнення int — це UB
Багато хто приходить у C++ з очікуванням: «ну, переповнилося й стало відʼємним, як у звичному двокомплементарному світі». Але за правилами мови переповнення signed цілого типу — UB.
#include <iostream>
#include <limits>
int main() {
int x = std::numeric_limits<int>::max();
#if 0
int y = x + 1; // UB: signed overflow
std::cout << y << '\n';
#endif
std::cout << x << '\n'; // 2147483647 (зазвичай)
}
Практичний висновок без фанатизму: якщо ви рахуєте суми, добутки, накопичуєте результат у циклі, думайте про діапазон типів. Іноді достатньо замінити int на long long. Іноді треба перевіряти межі. А UBSan корисний як сигнал: «ось тут ви справді порушили правила мови».
Некоректні зсуви: відʼємні й занадто великі
Бітові операції виглядають як арифметика, але в них є суворі вимоги: не можна зсувати на відʼємне число і не можна зсувати «занадто сильно».
#include <iostream>
int main() {
int x = 1;
int shift = -1;
#if 0
int y = x << shift; // UB: відʼємний зсув
std::cout << y << '\n';
#endif
std::cout << x << '\n'; // 1
}
На практиці такі помилки виникають, коли shift обчислюється з вхідних даних або з розміру типу чи масиву, а ви не перевірили межі.
5. Як вмикати ASan/UBSan у збиранні
Увімкнення санітайзерів майже завжди робиться прапорами компілятора. Не заглиблюватимемося в тонкощі різних платформ і рідкісних режимів: нам потрібен базовий робочий рецепт.
Типова форма команди
Якщо ви компілюєте з консолі (або IDE робить це за вас), загальна ідея така:
# ASan
g++ -std=c++23 -O0 -g -fsanitize=address main.cpp
clang++ -std=c++23 -O0 -g -fsanitize=address main.cpp
# UBSan
g++ -std=c++23 -O0 -g -fsanitize=undefined main.cpp
clang++ -std=c++23 -O0 -g -fsanitize=undefined main.cpp
Чому тут майже завжди використовують -O0 і -g?
Із -g санітайзер може показати вам нормальні «файл:рядок», а не загадкові адреси. Із -O0 поведінка ближча до «як написано», а звіти зазвичай зрозуміліші. Оптимізації не заборонені, але для навчання й діагностики на рівні початківця простіше починати з -O0.
В IDE та CMake: загальна ідея
Якщо проєкт на CMake, ви зазвичай додаєте прапори до цільового застосунку. Ідея приблизно така: це не «єдино правильний» варіант, а просто зрозумілий шаблон.
# Приклад-ідея: у CMakeLists.txt
target_compile_options(app PRIVATE -g -O0 -fsanitize=address)
target_link_options(app PRIVATE -fsanitize=address)
Суть у тому, що санітайзер — це не лише компіляція, а й лінкування: підключаються потрібні runtime-компоненти.
6. Практичний приклад: StepStats і пошук помилок
Далі ми розвиватимемо маленький консольний застосунок. Нехай він зберігає кроки за днями й рахує статистику. Це навмисно проста задача: ми хочемо, щоб увага була зосереджена на діагностиці, а не на тому, «як спроєктувати бізнес-логіку».
Базова структура даних
#include <vector>
struct StepStats {
std::vector<int> steps;
};
Жодної магії: steps — просто список значень.
Функція додавання значення
#include <vector>
void AddSteps(std::vector<int>& steps, int value) {
steps.push_back(value);
}
Це «нудний» код — і це добре. Чим більше у вас нудних функцій, тим менше несподіваних пригод.
Середнє: місце, де легко отримати UB
Середнє здається невинним, але тут є дві типові проблеми: «ділення на нуль» і «переповнення під час підсумовування». Почнімо з найбазовішої версії й подивімося, де тут ризик.
#include <vector>
double Average(const std::vector<int>& steps) {
int sum = 0;
for (int x : steps) sum += x;
return static_cast<double>(sum) / steps.size();
}
Якщо steps порожній, steps.size() дорівнює 0, і ділення перетворюється на UB. UBSan якраз любить такі місця: він не міркує, «а чи хотів програміст так», а просто повідомляє: «ділення на 0».
Щоб не лишати в коді «мін», зробімо безпечнішу версію:
#include <vector>
double AverageOrZero(const std::vector<int>& steps) {
if (steps.empty()) return 0.0;
long long sum = 0;
for (int x : steps) sum += x;
return static_cast<double>(sum) / steps.size();
}
Тут ми одночасно прибрали ділення на 0 і знизили ризик signed overflow, використавши long long для суми.
Типова помилка індексації: кандидат для ASan
Уявімо, що ми хочемо отримати «останнє значення». Початківець іноді пише так:
#include <vector>
int LastBad(const std::vector<int>& v) {
#if 0
return v[v.size()]; // UB: size() — не індекс останнього елемента
#else
return 0;
#endif
}
У реальному коді це може «працювати» роками, доки вектор малий і вам «щастить». Але з ASan шанси, що помилку спіймають, різко зростають.
Правильна безпечна версія може виглядати так:
#include <vector>
int LastOrZero(const std::vector<int>& v) {
if (v.empty()) return 0;
return v.back();
}
Так, back() — це «чесний спосіб» узяти останній елемент.
Міні-main, щоб звʼязати все разом
#include <iostream>
#include <vector>
int main() {
std::vector<int> steps;
steps.push_back(5000);
steps.push_back(8300);
std::cout << AverageOrZero(steps) << '\n'; // 6650
}
Якщо ви поекспериментуєте і зробите steps порожнім, то стара версія Average() дала б UB (ділення на 0). А UBSan допоміг би не «вгадувати» це, а одразу побачити.
7. Практична стратегія вибору санітайзера
Іноді здається, що правильна відповідь — «вмикати все завжди». На практиці це не завжди зручно: звітів може бути багато, програма може помітно сповільнитися, а ви ризикуєте потонути в діагностиці.
Практичніша стратегія така: якщо ви підозрюєте, що проблема в «індексі» або в тому, що «пишемо не туди», починайте з ASan. Якщо проблема схожа на «раптово впало на діленні, зсуві або підозрілій арифметиці», починайте з UBSan. Якщо ви не впевнені, а код невеликий, вмикайте обидва по черзі (або разом, якщо ваше середовище це підтримує) і дивіться, що вийде.
Головна звичка дня: не намагайтеся налагоджувати UB як звичайний баг. Спочатку виключаємо UB (санітайзер + перевірки), а вже потім зʼясовуємо, чому формула неправильна або чому UX сумний.
8. Типові помилки під час роботи з ASan/UBSan
Помилка № 1: намагатися «підключити санітайзер кодом».
Початківці інколи шукають #include або думають, що ASan — це якась функція на кшталт EnableAsan(). Насправді санітайзер вмикається прапорами збирання, бо він змінює машинний код: додає перевірки, змінює розкладку памʼяті навколо обʼєктів, підключає runtime-компоненти.
Помилка № 2: збирати із санітайзером, але без налагоджувальної інформації.
Санітайзер і без -g інколи може показати адреси та частину стеку, але для навчання і швидкої локалізації проблеми майже завжди хочеться бачити main.cpp:123. Тому в діагностичному режимі залишайте -g, навіть якщо ви взагалі не любите дебагери.
Помилка № 3: очікувати, що санітайзер знайде логічні помилки.
Якщо програма рахує середнє не так, плутає валюти або неправильно сортує, санітайзери не зобовʼязані цього помічати. Вони ловлять класи помилок, повʼязані з памʼяттю та UB, але не перевіряють «сенс задачі». Для сенсу задачі потрібні тести, уважність і, інколи, добра кава.
Помилка № 4: усувати симптом, а не причину.
Іноді санітайзер свариться на рядок return v[i];, і рука тягнеться «поправити тут». Але часто причина вище: i став неправильним ще в іншій функції. Варто памʼятати: місце, де помилку виявлено, не завжди є місцем, де вона виникла.
Помилка № 5: думати, що якщо санітайзер мовчить, то UB точно немає.
Санітайзери дуже корисні, але вони не дають математичної гарантії «все чисто». Вони підвищують імовірність спіймати помилки й роблять діагностику набагато зручнішою, але дисципліна перевірок (межі, empty(), діапазони типів) усе одно залишається вашим обовʼязком.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ