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 всё падает.
И вот здесь вспоминаем работу с отладчиком: call stack полезен, потому что проблема может проявиться в одном месте, а причина — в другом. 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 приходит из данных (или вычисляется), его нужно ограничить. Иначе ошибка будет выглядеть случайной: «вчера работало, сегодня печатает нули».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ