Неопределённое поведение
Почему «локально работает, а на сервере падает» — это почти всегда ваша ошибка. Каталог случаев и способ их ловить.
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; // неопределённое поведение
Не «получится », а именно неопределённое поведение: компилятор вправе решить, что переполнения не бывает, и выкинуть проверку 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)).
Вместо ошибки сегментации без объяснений — готовый диагноз. Подробнее про флаги — в статье про отладку.
Что из этого следует
Практический вывод один и он неприятный: если поведение программы зависит от компилятора, уровня оптимизации или фазы луны — ошибка в коде, а не в тестирующей системе.
Вторая мысль полезнее. Неопределённое поведение опасно не тем, что программа падает, а тем, что она не падает. Ошибка живёт в коде месяцами, проходит все ваши тесты и срабатывает на закрытых. Поэтому санитайзеры включают не когда что-то сломалось, а всегда.