Menu

Неопределённое поведение в C++: что это и как его избежать

Неопределённое поведение (UB) — это код, на который стандарт C++ не накладывает никаких правил: он может упасть, испортить данные или казаться работающим. Узнайте о частых причинах, почему «всё запустилось нормально» ничего не доказывает, и об инструментах, которые ловят UB.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

Что на самом деле означает «неопределённое поведение»

Предыдущая страница показала, как try/catch обрабатывает ошибки, которые ваша программа определяет и бросает намеренно. Неопределённое поведение — это противоположность: набор операций, которым стандарт C++ отказывается придавать хоть какой-то смысл. Нет исключения, которое можно перехватить, нет кода ошибки, нет гарантии падения. Компилятор волен считать, что UB никогда не происходит, и делать что угодно, когда оно всё-таки случается.

Именно эта свобода делает UB таким опасным. Одна и та же ошибочная строка может вывести «правильный» ответ на вашем ноутбуке, вернуть мусор на сервере и быть полностью удалена оптимизатором при -O2. UB — это не «поведение, которое мы не задокументировали», а «поведение, насчёт которого язык ничего не обещает». Ваша задача — вообще никогда его не писать.

int arr[3] = {1, 2, 3};
int x = arr[5];   // неопределённое поведение: чтение за концом массива

Здесь нет ошибки компиляции, и во многих запусках он тихо подсунет вам случайное целое число. Этот кажущийся успех и есть ловушка.

Чтение или запись за границами

Самая распространённая форма UB — обращение к памяти, которая вам не принадлежит. Встроенные массивы и std::vector::operator[] не выполняют проверку границ — индекс за концом (или отрицательный) — это мгновенный UB, читаете вы или пишете.

Ошибка, за которой нужно следить, — это <= там, где вы имели в виду <: когда i == v.size(), вы индексируете элемент за последним, что является UB. Предпочитайте цикл for на основе диапазона (рассмотрен ранее), когда индекс не нужен, ведь он не может выйти за конец. Когда вы действительно индексируете вручную и хотите подстраховаться, v.at(i) бросает std::out_of_range вместо молчаливой порчи памяти:

Используйте at(), пока охотитесь за ошибкой; вернитесь к [] в горячих циклах, как только докажете, что индексы корректны.

Висячие указатели и use-after-free

Указатель или ссылка, которые переживают объект, на который указывают, — висячие (dangling). Их использование — это UB: память могла быть переиспользована, освобождена или никогда не существовать. Это ловушка, которую помогают избежать умные указатели (из предыдущей главы), но сырые указатели всё ещё позволяют в неё попасть.

Самый явный вариант — возврат адреса локальной переменной. Локальная переменная умирает при возврате из функции, так что вызывающий остаётся с указателем в никуда:

int* makeNumber() {
    int n = 42;
    return &n;   // возвращает адрес локальной переменной - после return её нет
}
// Разыменование результата — неопределённое поведение.

То же самое происходит после delete или когда vector выполняет реаллокацию и инвалидирует итераторы или указатели на него:

int* p = new int(5);
delete p;
cout << *p;   // use-after-free: неопределённое поведение

vector<int> v = {1, 2, 3};
int* first = &v[0];
v.push_back(4);   // может выполнить реаллокацию - 'first' теперь висячий
cout << *first;   // неопределённое поведение

Меры защиты — те, что вы уже знаете: держите объекты живыми, пока их нужен хоть один указатель, предпочитайте ссылки и умные указатели сырым владеющим указателям и заново получайте указатели/итераторы после любой операции, которая может изменить размер контейнера.

Неинициализированные переменные и переполнение знакового типа

Чтение переменной до того, как вы дали ей значение, — это UB для встроенных типов: никакого 0 по умолчанию нет. Переменная содержит те биты, что уже были в этой памяти, а оптимизатор может считать, что вы никогда не читаете её неинициализированной.

Если бы sum была объявлена как простое int sum;, каждое sum += i сначала читало бы неопределённое значение: UB и печально известная своей сложностью ошибка, потому что часто кажется, что всё работает. Сделайте инициализацию привычкой: int x = 0; или int x{};.

Ещё один тихий нарушитель — переполнение знакового целого. Вытолкнуть знаковый int за его максимум — это UB (беззнаковые типы переполняются предсказуемо; знаковые — нет):

int big = 2147483647;   // INT_MAX для 32-битного int
int oops = big + 1;     // переполнение знакового типа: неопределённое поведение

Не рассчитывайте, что оно «перевернётся в отрицательное число» — компилятору разрешено считать, что переполнение невозможно, и оптимизировать исходя из этого. Если вам нужен определённый перенос, используйте беззнаковый тип или проверяйте границы перед сложением.

Ловим UB санитайзерами и предупреждениями

Тестами уверенности в отсутствии UB не добиться, потому что успешный запуск ничего не гарантирует. Что действительно работает — это сделать UB громким во время выполнения с помощью санитайзеров компилятора (доступны в GCC и Clang).

// AddressSanitizer: выход за границы, use-after-free, утечки
g++ -fsanitize=address -g -O1 main.cpp -o app && ./app

// UndefinedBehaviorSanitizer: переполнение знакового типа, разыменование null, некорректные приведения
g++ -fsanitize=undefined -g main.cpp -o app && ./app

Прогоните существующие тесты с этими флагами, и чтение за границами, use-after-free или переполнение знакового типа, которое «работало нормально», превращается в точный отчёт с именем файла и строкой. Сочетайте их с -Wall -Wextra, чтобы компилятор отмечал ещё и подозрительный код (например, вероятное чтение неинициализированной переменной) ещё до запуска.

==1234==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
READ of size 4 at 0x... thread T0
    #0 main.cpp:7 in main

Относитесь к любому отчёту санитайзера как к ошибке, которую обязательно нужно исправить, а не как к предупреждению, которое можно проигнорировать, — он говорит вам, что стандарт ничего не обещает насчёт этой строки.

Итоги

Неопределённое поведение — это та часть C++, где защитные ограждения снимаются: выход за границы, висячие указатели, use-after-free, чтение неинициализированных данных и переполнение знакового типа — всё это порождает код без какого-либо определённого смысла, и «всё запустилось нормально» никогда не доказывает его правильность. Способ оставаться в безопасности — писать защитно (инициализируйте каждую переменную, соблюдайте границы контейнеров, отдавайте владение памятью в куче умным указателям), а затем проверять с помощью -fsanitize=address, -fsanitize=undefined и -Wall -Wextra, чтобы молчаливый UB становился громким, исправимым отчётом.

На этом завершается глава «Ошибки и отладка». Между исключениями, try/catch и здоровым страхом перед UB у вас теперь есть инструменты, чтобы писать C++, который падает громко и намеренно, а не тихо и случайно.

Часто задаваемые вопросы

Что такое неопределённое поведение в C++?

Неопределённое поведение (UB) — это любая операция, которой стандарт C++ явно не даёт определённого результата; например, чтение за концом массива или разыменование висячего указателя. Компилятору разрешено делать что угодно: упасть, вернуть мусор, выкинуть код при оптимизации или сегодня казаться работающим, а после перекомпиляции сломаться. Это ошибка в вашей программе, а не возможность языка.

Почему моя программа на C++ работает, хотя в ней есть неопределённое поведение?

«Всё запустилось нормально» ничего не доказывает насчёт UB. Стандарт не даёт никаких гарантий ни в ту, ни в другую сторону, поэтому ошибка с UB может выдать ожидаемый результат на вашей машине с вашим компилятором сегодня, а затем упасть на другом уровне оптимизации, платформе или версии компилятора. Никогда не считайте успешный запуск доказательством того, что UB безвреден — используйте санитайзер, чтобы действительно его поймать.

Как поймать неопределённое поведение в C++?

Компилируйте с санитайзерами: -fsanitize=address (AddressSanitizer) находит чтение/запись за границами и use-after-free, а -fsanitize=undefined (UndefinedBehaviorSanitizer) помечает переполнение знакового типа, разыменование null и некорректные приведения. Включите предупреждения (-Wall -Wextra) и прогоняйте свои тесты с этими флагами — они превращают молчаливый UB в понятный отчёт во время выполнения.

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ