EduBrick

Неопределённое поведение

Почему «локально работает, а на сервере падает» — это почти всегда ваша ошибка. Каталог случаев и способ их ловить.

4 мин

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

Это называется неопределённым поведением, и именно оно стоит за фразой «у меня локально работает, а в тестирующей системе неверный ответ».

Компилятор при этом ведёт себя логично со своей точки зрения: раз такого быть не может, значит, можно оптимизировать в предположении, что этого нет. Поэтому одна и та же программа даёт разный результат с -O0 и с -O2.

Неинициализированная переменная

int x;
cout << x;   // неопределённое поведение

Локально там почти всегда окажется ноль — операционная система выдаёт обнулённые страницы. На сервере, где память уже использовалась, окажется что угодно.

То же с полями структур: без инициализаторов поля содержат мусор. Пишите int age = 0; прямо в объявлении.

Выход за границы

vector<int> a(5);
a[7] = 1;   // неопределённое поведение

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

Проверку даёт a.at(7) — она бросит исключение. Или флаг -D_GLIBCXX_DEBUG, который включает проверки во всех контейнерах сразу.

Функция без return

int compare(int a, int b) {
    if (a < b) return -1;
    if (a > b) return 1;
    // при a == b не возвращаем ничего
}

Компилятор выдаёт предупреждение, но собирает. Что происходит дальше — проверено на GCC 15: с -O0 программа падает с «недопустимой инструкцией», с -O2 — с ошибкой сегментации. Один и тот же исходник, два разных способа умереть.

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

Исключение единственное — main, где return 0 подразумевается.

Нестрогий компаратор

sort(a.begin(), a.end(), [](int x, int y) { return x <= y; });

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

То же относится к operator< в структуре и к компаратору set или priority_queue. Правило: сравнение всегда строгое, comp(x, x) всегда ложно.

Испорченный итератор

Самый коварный случай. Удаление элемента из set во время обхода:

for (auto it = s.begin(); it != s.end(); ++it)
    if (*it % 2 == 0) s.erase(it);   // так нельзя

После erase итератор указывает на освобождённую память, и ++it работает с ней. На GCC 15 этот цикл падает с ошибкой сегментации и с -O0, и с -O2. На других сборках он может отработать «нормально», пропустив часть элементов, — и это опаснее падения.

Правильно — использовать то, что возвращает erase:

for (auto it = s.begin(); it != s.end(); )
    if (*it % 2 == 0) it = s.erase(it);
    else ++it;

erase возвращает итератор на следующий элемент. Заметьте: в заголовке цикла инкремента больше нет — он переехал в ветку else.

У vector итераторы портятся ещё легче: любой push_back может переселить весь массив в другое место памяти, и все сохранённые итераторы, указатели и ссылки становятся недействительными. Правило простое: сохранённый итератор живёт до первого изменения контейнера.

Знаковое переполнение

int i = 1000000;
cout << i * i;   // неопределённое поведение

Не «получится 727379968-727379968», а именно неопределённое поведение: компилятор вправе решить, что переполнения не бывает, и выкинуть проверку if (i * i < 0) как заведомо ложную.

У беззнаковых типов, наоборот, поведение определено — они заворачиваются по кругу. Это одна из немногих ситуаций, где unsigned предпочтительнее.

Как ловить

Почти всё перечисленное находится одной строкой при компиляции:

g++ -std=c++20 -g -fsanitize=address,undefined -D_GLIBCXX_DEBUG solution.cpp -o solution

Санитайзеры печатают точную строку и причину. Пример вывода на нестрогом компараторе:

Error: comparison doesn't meet irreflexive requirements, assert(!(a < a)).

Вместо ошибки сегментации без объяснений — готовый диагноз. Подробнее про флаги — в статье про отладку.

Что из этого следует

Практический вывод один и он неприятный: если поведение программы зависит от компилятора, уровня оптимизации или фазы луны — ошибка в коде, а не в тестирующей системе.

Вторая мысль полезнее. Неопределённое поведение опасно не тем, что программа падает, а тем, что она не падает. Ошибка живёт в коде месяцами, проходит все ваши тесты и срабатывает на закрытых. Поэтому санитайзеры включают не когда что-то сломалось, а всегда.