1. Що таке UB і чому він небезпечний
Якщо ви коли-небудь ловили баг на кшталт «іноді все нормально, а іноді все падає», то вже емоційно знайомі з сьогоднішньою темою. За звичайної логічної помилки програма працює передбачувано, просто видає неправильний результат. Наприклад, ви неправильно порахували середнє. А от UB — це ситуація, коли ви порушили правила мови C++, і далі мова взагалі нічого не гарантує: програма може «випадково працювати», може падати, може друкувати нісенітницю, а може навіть виглядати коректною — до першого релізу.
UB важливий не лише як «рідкісна страшилка для олімпіадників». Насамперед він важливий тому, що саме UB робить налагодження схожим на детектив: наслідок видно, а причина може бути далеко й проявлятися по-різному на різних компʼютерах, у різних компіляторах і налаштуваннях.
Чому UB страшніший за «звичайний баг»
У разі логічної помилки програма зберігає гарантії мови: вирази обчислюються за правилами, памʼять залишається памʼяттю, контейнери — контейнерами. Так, ви могли неправильно скласти числа, але поведінка буде повторюваною.
У випадку UB ви порушуєте контракт мови. І в цьому ключова відмінність: після UB компілятор і середовище виконання вам нічого не винні.
Робоче визначення Undefined Behavior
Уявіть, що C++ — це настільна гра з дуже строгими правилами. Поки ви ходите за правилами, гра гарантує, що кубик має значення 1–6, картки читаються однаково, а перемога рахується чесно. UB — це момент, коли ви берете кубик, розрізаєте його навпіл і кажете: «Тепер у мене випало 9». Після цього ніхто не зобовʼязаний пояснювати, що відбувається: ви вийшли за межі контракту гри.
Формально стандарт C++ у подібних місцях використовує дуже пряме формулювання: «the behavior is undefined» («поведінка не визначена»).
Важливий практичний висновок: UB — це не «програма точно впаде». UB — це «мова перестала давати гарантії». А без гарантій компілятор має право робити оптимізації, які ламають ваші очікування, оскільки ви самі неявно пообіцяли: «я так не роблю».
2. Чому UB «іноді працює»
Новачкові це здається логічним: «Якщо рядок коду неправильний, він має ламатися завжди однаково». Але C++ — мова, у якій компілятор дуже активно перебудовує код заради швидкодії, особливо в Release. Саме тут UB стає токсичним: компілятор оптимізує програму, припускаючи, що UB не відбувається, бо коректна програма не має права його породжувати.
Нижче — проста схема того, як це зазвичай виглядає в реальному житті:
flowchart TD
A[У коді є помилка] --> B{Це логічна помилка?}
B -->|Так| C[Результат неправильний, але поведінка стабільна]
B -->|Ні, це UB| D[Поведінку не визначено]
D --> E[У Debug «працює»]
D --> F[У Release ламається]
D --> G[Ламається не там, де причина]
D --> H[На іншому компіляторі проявляється інакше]
Із UB часто виникає такий «ефект привида»: ви змінюєте std::cout в одному місці, просто додаєте виведення, і раптом «зникає» падіння. Не тому, що ви виправили проблему, а тому, що змінилося розташування коду й памʼяті, і UB почав проявлятися інакше.
3. Типові джерела UB без new/delete та вказівників
Гарна новина: щоб отримати UB, вам не потрібні ні new, ні вказівники, ні «страшні системні штуки». Погана новина: достатньо найпростіших операцій, якими ми вже активно користуємося.
Сьогодні розберемо чотири найпоширеніші джерела, які справді трапляються і в навчальних, і в реальних програмах: вихід за межі, ділення на нуль, signed overflow і некоректний зсув.
Для орієнтира — компактна табличка «що сталося → чому це UB»:
| Ситуація | Приклад | Чому UB |
|---|---|---|
| Вихід за межі масиву/вектора | |
ви звертаєтеся до памʼяті, яка не є елементом контейнера |
| Ділення цілого на 0 | |
операція порушує вимоги мови |
| Переповнення int | |
signed overflow у C++ — UB |
| Некоректний зсув | |
для зсуву є строгі вимоги до аргументів |
Тепер розберемо кожен пункт на невеликих прикладах. Важливо: небезпечні рядки буде вимкнено так, щоб за замовчуванням вони не виконувалися. Ми ж не хочемо, щоб ваша програма перетворилася на генератор випадкових чисел під виглядом статистики.
Вихід за межі std::vector
Зазвичай студент уперше стикається з цим так: бачить size() і думає: «Ага, останній елемент». Але size() — це кількість елементів, а індекси починаються з нуля. Тому останній індекс — це size() - 1, і то лише тоді, коли контейнер не порожній.
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{10, 20, 30};
std::size_t i = v.size(); // i == 3, але допустимі індекси: 0..2
#if 0
std::cout << v[i] << '\n'; // UB: вихід за межі при operator[]
#endif
std::cout << v[0] << '\n'; // 10
}
Найпідступніше тут те, що operator[] не зобовʼязаний перевіряти межі. Він швидкий і просто довіряє вам. Якщо ви помилилися, то читаєте «чужу» памʼять, а далі можливі будь-які спецефекти.
Ділення на нуль у цілих типах
Ділення на нуль у цілих типах — це не просто «математика сказала: не можна». У C++ це саме порушення вимог операції. У деяких середовищах ви отримаєте аварійне завершення, у деяких — дивний результат, а десь оптимізатор узагалі може переставити код так, що ви ловитимете наслідки вже в іншому місці.
#include <iostream>
int main() {
int a = 10;
int b = 0;
#if 0
int c = a / b; // UB: division by zero (для цілих типів)
std::cout << c << '\n';
#endif
std::cout << a << '\n'; // 10
}
Корисна звичка тут проста: якщо дільник потенційно може бути нулем, це треба явно обробити умовою або гарантувати логікою так, щоб нуль був неможливим.
Signed overflow
Багато хто очікує поведінки «як у 8-бітному калькуляторі»: був максимум, став мінімум. Але в C++ переповнення signed-типу (int, long long, якщо тип саме signed) — Undefined Behavior. Тобто не можна писати код, який «розраховує» на таке переповнення.
#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)
}
Якщо ви ловите себе на думці «та там усього +1, що може статися», то це і є класична стежка до UB. Особливо коли це «усього +1» заховане всередині циклу на мільйон ітерацій.
Некоректні зсуви
Зсуви виглядають як «множення на два в двійковій системі», але для них є дуже строгі правила: зсув не повинен бути відʼємним і не повинен бути більшим або рівним ширині типу. Умовно кажучи, для 32-бітного int не можна зсувати на 32 і більше. Порушили — UB.
#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
}
Зсуви часто спливають у масках, прапорцях, кодуванні станів. Навіть якщо ви не пишете низькорівневий код, усе одно можете натрапити на це в цілком звичайній логіці.
4. Практичний приклад: StudyLog без UB
Щоб UB не залишався абстракцією, давайте привʼяжемо його до чогось життєвого. Уявімо, що в нас є навчальний консольний застосунок StudyLog: він зберігає список навчальних сесій — тема плюс кількість хвилин занять, — а потім рахує просту статистику. Ми вже робили подібні речі раніше: struct, std::vector, функції й акуратне введення — усе це вам знайоме.
Почнемо з моделі даних:
#include <string>
struct StudySession {
std::string topic;
int minutes = 0;
};
Тепер напишемо функцію, яка безпечно повертає елемент за індексом. Саме тут і зʼявляється ризик виходу за межі. Ми поки що не розглядаємо винятки як окрему тему — нам достатньо просто не допускати UB.
#include <vector>
#include <optional>
std::optional<StudySession> GetSession(const std::vector<StudySession>& sessions,
std::size_t index) {
if (index >= sessions.size()) {
return std::nullopt;
}
return sessions[index];
}
Зверніть увагу на сам підхід: ми не намагаємося «майже правильно» прочитати елемент. Ми або повертаємо значення, або чесно кажемо: «його немає». Це різко знижує ймовірність UB, бо проблему ми виявляємо ще до небезпечного доступу.
Тепер середнє значення. Тут пастка вже інша: ділення на нуль, якщо список порожній.
#include <vector>
double AverageMinutes(const std::vector<StudySession>& sessions) {
if (sessions.empty()) {
return 0.0; // домовилися: порожньо -> середнє 0
}
int total = 0;
for (const auto& s : sessions) {
total += s.minutes;
}
return static_cast<double>(total) / sessions.size();
}
Тут важливо, що ми явно обробляємо порожній контейнер. Якби ми написали total / sessions.size() без перевірки, то за size() == 0 отримали б UB.
Залишилася ще хитріша штука: переповнення total. Припустімо, хтось завантажив дані за кілька років, і хвилин стало дуже багато. На малих обсягах це може не проявитися, а потім раптово «зламається» в Release — класичний сюжет для UB.
Один із простих способів зменшити ризик — підсумовувати в ширшому типі:
#include <vector>
long long TotalMinutes(const std::vector<StudySession>& sessions) {
long long total = 0;
for (const auto& s : sessions) {
total += static_cast<long long>(s.minutes);
}
return total;
}
Це не «магічний захист від усього», але для навчальних задач такий підхід уже значно розумніший, ніж складати все в int і сподіватися на краще.
Насамкінець — невеликий бонусний приклад про зсуви. Припустімо, ви захотіли зберігати прапорці «що саме робили»: читали, розвʼязували задачі, дивилися лекцію. Це можна кодувати бітовою маскою, але не можна дозволяти користувачу або коду зсувати на «що завгодно».
#include <cstdint>
std::uint32_t MakeFlag(int bit) {
if (bit < 0 || bit >= 32) {
return 0u; // некоректний біт -> повернемо 0, без UB
}
return 1u << bit;
}
Сенс усіх цих прикладів один: UB найчастіше зʼявляється там, де в операції є передумови, а ми їх не перевірили й не гарантували.
5. Як відрізнити UB від звичайної помилки
Є один прикрий момент: UB рідко приходить із табличкою «Вітаю, я UB». Зазвичай ви бачите лише симптоми. І якщо навчитеся їх упізнавати, то швидше почнете думати в правильному напрямі: спочатку виключаємо UB, а вже потім шукаємо логічну помилку.
Звичайна логічна помилка поводиться стабільно: наприклад, ви завжди помиляєтеся в середньому на 1. UB поводиться так, ніби «живе своїм життям»: сьогодні працює, завтра ні, після додавання std::cout раптом ніби «полагодилося», а в Release усе падає.
Тут варто згадати й роботу з налагоджувачем: стек викликів корисний, бо проблема може проявитися в одному місці, а причина — в іншому. UB особливо любить «ламатися» пізніше: ви вийшли за межі вектора, пошкодили памʼять, а впали через 200 рядків у зовсім іншій функції.
Діагностика UB: навіщо потрібні санітайзери
Коли ви підозрюєте UB, хочеться мати інструмент, який скаже: «Ось тут ви зробили заборонену дію». Під час звичайного запуску програма може мовчати й продовжувати працювати — і саме цим UB небезпечний. Тому існують діагностичні режими збирання та запуску, які додають додаткові перевірки під час виконання.
У C++ такими інструментами часто є санітайзери. Якщо спростити, вони роблять дуже просту річ: компілятор «інструментує» програму, додаючи перевірки навколо небезпечних місць, і в разі порушення умов видає звіт.
Тут достатньо запамʼятати одну думку: санітайзери не лікують код, вони допомагають спіймати UB ближче до місця, де він виник. А от детально вмикати ASan/UBSan і читати звіти — це вже теми окремих лекцій.
6. Типові помилки
Помилка №1: «Якщо один раз спрацювало, значить, так можна».
Це одна з найдорожчих звичок у C++. UB може «не проявлятися» на малих даних, у Debug, на вашому компʼютері й до першої зміни компілятора. Якщо код порушує передумову операції — межі, дільник, діапазон типу, — він залишається неправильним навіть тоді, коли сьогодні виглядає «нормально».
Помилка №2: плутанина між size() та «останнім індексом».
size() — це кількість елементів, а не індекс. Під час індексації допустимі значення від 0 до size()-1 включно, якщо контейнер не порожній. Помилка v[v.size()] виглядає дрібною, але за наслідками може бути катастрофічною, бо operator[] не зобовʼязаний перевіряти межі.
Помилка №3: ділення на нуль у надії, що «якось обробиться».
У цілочисельній арифметиці ділення на нуль — не «помилка обчислення», а UB. Його не можна залишати «на авось». Дільник має бути перевірений або гарантований логікою програми, інакше ви будуєте дім на піску.
Помилка №4: арифметика на межах int, ніби переповнення — це просто «перелилося».
У C++ signed overflow — це UB, тому код, який розраховує на «циклічне переповнення», логічно некоректний. Якщо є ризик великих значень, використовуйте ширший тип для проміжних обчислень і завжди тримайте в голові діапазони.
Помилка №5: бітові зсуви без контролю аргументів.
Зсуви потребують коректного діапазону: не можна зсувати на відʼємне число й не можна зсувати надто далеко. Якщо shift приходить із даних або обчислюється, його потрібно обмежити. Інакше помилка виглядатиме випадковою: «учора працювало, сьогодні друкує нулі».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ