Мусор в переменных и неопределённое поведение в C++

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

Содержание

Программа считает сумму трёх чисел и выводит −1094644850. Или вчера работала, а сегодня падает. Или на Вашем компьютере ответ верный, а проверяющая система пишет «неправильный ответ». У таких историй обычно одна причина — неопределённое поведение.

В статье — четыре самых частых его источника у начинающих. Все результаты получены настоящими запусками: компилятор g++ 14.2, Linux. На другой системе числа будут другими, и в этом вся суть.

Переменная без начального значения

C++
#include <iostream>

int main() {
    int sum;
    for (int i = 1; i <= 3; ++i) {
        sum += i;
    }
    std::cout << sum << '\n';
    return 0;
}

Ожидается 6. Мы собрали эту программу тремя способами.

Как собранаЧто вывела
g++ main.cpp6
g++ -O2 main.cpp6
g++ -g -fsanitize=address,undefined main.cpp−1094644850

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

Почему так происходит

Объявление int sum; выделяет место в памяти, но ничего туда не записывает. В переменной остаётся то, что лежало в этих байтах раньше, — следы работы других функций. Иногда там нули, и сумма случайно получается верной. Иногда нет.

В отличие от многих других языков, C++ не обнуляет локальные переменные сам: это стоило бы времени, а язык не заставляет платить за то, о чём Вы не просили.

Исправление — одна правка:

C++
int sum = 0;

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

Стандарт C++ описывает, что должна делать правильная программа. Для некоторых ошибок он не описывает ничего: «поведение не определено» (undefined behavior, сокращённо UB). Компилятор в таком случае вправе сделать что угодно, и на практике это выглядит так:

  • программа работает правильно — пока что;
  • выводит мусор;
  • падает;
  • ведёт себя по-разному в зависимости от настроек сборки.

Компилятор не проверяет, есть ли в программе UB. Он исходит из того, что его нет, и оптимизирует код с этим допущением. Отсюда самые странные эффекты, как в примере с циклом ниже.

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

C++
int scores[3] = {5, 4, 3};
int total = 0;
for (int i = 0; i <= 3; ++i) {     // должно быть i < 3
    total += scores[i];
}
std::cout << total << '\n';

В массиве три элемента с номерами 0, 1 и 2. Условие i <= 3 заставляет прочитать четвёртый, которого нет. Программа прочитает то, что лежит в памяти сразу за массивом.

Как собранаЧто вывела
g++ main.cpp65547
g++ -O2 main.cpp12

Правильный ответ — 12, и с оптимизацией он случайно получился. Без оптимизации к сумме прибавилось чужое число.

Чтение за границей — ещё полбеды. Запись туда портит соседние переменные или приводит к падению; об этом — статья «Segmentation fault в C++».

Функция без return

C++
int sign(int x) {
    if (x > 0) return 1;
    if (x < 0) return -1;
}                                   // а если x == 0?

int main() {
    std::cout << sign(0) << '\n';
}

Для нуля функция доходит до закрывающей скобки, ничего не вернув. Это не «вернётся ноль» и не «вернётся мусор». У нас программа упала в обоих вариантах сборки: без оптимизации — с сообщением Trace/breakpoint trap, с -O2 — с Segmentation fault.

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

Переполнение int

Тип int обычно хранит числа примерно от −2,1 до 2,1 миллиарда. Что будет, если выйти за эти границы?

C++
int price = 2000000000;
int total = price + price;
std::cout << total << '\n';         // -294967296

Привычное объяснение — «число завернулось». Часто так и выглядит, но стандарт этого не обещает: переполнение знакового целого — тоже неопределённое поведение. Вот программа, которая это показывает:

C++
for (int i = 0; i < 4; ++i) {
    std::cout << i * 1000000000 << '\n';
}

Цикл на четыре шага. Без оптимизации он выводит четыре числа:

TXT
0
1000000000
2000000000
-1294967296

С флагом -O2 он не останавливается:

TXT
0
1000000000
2000000000
-1294967296
-294967296
705032704
1705032704
...

Компилятор рассуждал так: раз переполнения в правильной программе не бывает, значит, i * 1000000000 всегда помещается в int, значит, i меньше трёх, значит, условие i < 4 истинно всегда — и проверку можно выбросить. Ошибка в одной строке изменила поведение совсем другой.

Исправление — тип, в который результат помещается:

C++
long long total = 2LL * price;

Как ловить такие ошибки

Включите предупреждения

Bash
g++ -std=c++26 -Wall -Wextra main.cpp -o main

На примерах из этой статьи компилятор сообщил:

TXT
warning: 'sum' is used uninitialized [-Wuninitialized]
warning: control reaches end of non-void function [-Wreturn-type]
warning: array subscript 3 is above array bounds of 'int [3]' [-Warray-bounds=]
warning: iteration 3 invokes undefined behavior [-Waggressive-loop-optimizations]

Все четыре ошибки были найдены ещё до запуска. Часть этих предупреждений появляется только вместе с оптимизацией, поэтому при проверке добавляйте -O2. Как читать сообщения компилятора — в статье «Как читать ошибки компилятора C++».

Запустите с санитайзерами

Санитайзер — режим сборки, в котором программа сама проверяет свои действия и останавливается с объяснением.

Bash
g++ -std=c++26 -g -fsanitize=address,undefined main.cpp -o main
./main

Что он написал о наших примерах:

TXT
main.cpp:7:26: runtime error: index 3 out of bounds for type 'int [3]'
main.cpp:5:40: runtime error: signed integer overflow: 3 * 1000000000 cannot be represented in type 'int'

Указаны файл, строка и суть. Программа с санитайзерами работает медленнее, поэтому это режим для проверки, а не для отправки решения.

Пишите так, чтобы ошибке было негде появиться

  • Давайте значение каждой переменной при объявлении: int count = 0;, double average = 0.0;, bool found = false;.
  • Объявляйте переменную там, где она получает смысл, а не в начале функции «про запас».
  • Для проверки границ используйте .at(): scores.at(3) у std::vector не читает чужую память, а останавливает программу с сообщением vector::_M_range_check: __n (which is 3) >= this->size() (which is 3).
  • Проверяйте, что каждая ветка функции заканчивается return.
  • Для больших чисел берите long long. Перемножаете два числа до миллиарда — результат в int не поместится.

Что изменилось в C++26

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

Ошибкой такой код быть не перестал, и остальные три случая из этой статьи остались неопределённым поведением. Подробнее — в обзоре «Что нового в C++26».

Что проверить, если программа ведёт себя странно

  1. У всех ли переменных есть начальное значение?
  2. Не выходит ли индекс за границы: i < n или i <= n?
  3. Возвращает ли функция значение во всех ветках?
  4. Помещается ли результат вычислений в тип?
  5. Что говорит компилятор с -Wall -Wextra -O2?
  6. Что говорит запуск с -fsanitize=address,undefined?

Если ответ меняется от запуска к запуску, от компьютера к компьютеру или после добавления отладочного вывода — это почти всегда один из первых четырёх пунктов.

Как целые числа хранятся в памяти и почему у типа есть границы, рассказывает урок учебника «Целые числа в памяти: знак, дополнительный код и переполнение».

Источники

  1. Undefined behavior — cppreference.com [Электронный ресурс]. — URL: https://en.cppreference.com/w/cpp/language/ub (дата обращения: 10.10.2026).
  2. Default-initialization — cppreference.com [Электронный ресурс]. — URL: https://en.cppreference.com/w/cpp/language/default_initialization (дата обращения: 10.10.2026).
  3. Program Instrumentation Options — GCC Manual [Электронный ресурс]. — URL: https://gcc.gnu.org/onlinedocs/gcc/Instrumentation-Options.html (дата обращения: 10.10.2026).
  4. P2795R5: Erroneous behaviour for uninitialized reads [Электронный ресурс]. — URL: https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2024/p2795r5.html (дата обращения: 10.10.2026).
C++КомпиляторПамять