Ошибки и неопределённое поведение
Как компилятор читает программу, почему код падает и чем опасно UB.
Стадии программы и виды ошибок
Когда программа ведёт себя неправильно, сначала полезно определить, на каком этапе возникла проблема. Если исполняемый файл ещё не получен, искать причину в введённых пользователем числах рано. Если программа уже запустилась, сообщение об ошибке компиляции от прошлой сборки может вообще не относиться к текущему запуску. Разделение на этапы помогает выбрать правильный следующий шаг, а не менять код наугад.
Ошибка до запуска, авария во время выполнения и неопределённое поведение — не одно и то же. Причём UB может проявиться без видимого падения.
Упрощённая схема: реальные этапы компилятора устроены сложнее и могут перекрываться.
| Ситуация | Когда | Что наблюдаем |
|---|---|---|
CE — Compilation Error | При компиляции исходного кода | Диагностика; исполняемый файл этой сборки не создан |
Ошибка компоновки | При связывании объектных файлов | Например, undefined reference или multiple definition |
RE — Runtime Error | Программа запустилась | Аварийное завершение, исключение без обработчика, сигнал ОС |
UB — Undefined Behavior | Корректность программы нарушена | Никаких требований к результату: от «всё работает» до неожиданной оптимизации |
Ошибка — это не всегда падение
CE обнаруживается до запуска; RE — наблюдаемая проблема во время исполнения. UB описывает отсутствие гарантий стандарта: оно может привести к RE, но может остаться незаметным.
Ошибка компоновки выделена отдельно: эта стадия рассматривается отдельно.
Compile time (время компиляции) — Этап подготовки программы к исполнению: проверки корректности и преобразование исходного кода в машинный.
Runtime (время выполнения) — Период, когда уже запущенная программа выполняет машинные инструкции.
Следите, чтобы после неудачной сборки вы случайно не запускали старый исполняемый файл. Иначе легко получить обманчивую картину: исходник изменён, а поведение осталось прежним. Полезная последовательность для учебного примера — сохранить файл, убедиться в успешной сборке, запустить именно полученную программу и отдельно проверить её результат. При этом даже успешная сборка означает не больше, чем прохождение выполненных компилятором проверок.
Предупреждения компилятора
Предупреждение полезно воспринимать как вопрос компилятора к вашему намерению: «Вы точно хотели написать именно это?». Например, присваивание внутри условия может быть допустимой конструкцией, но часто возникает из-за пропущенного второго знака равенства. Программа при этом может собраться и сделать совсем не то, что требовалось. Поэтому значение предупреждения определяется его причиной, а не только тем, разрешён ли запуск.
Компилятор заметил подозрительное, но допустимое действие. Например, if (x = 0) синтаксически корректно, но чаще программист хотел сравнить x == 0.
Зачем нужны предупреждения?
Они дают шанс найти ошибку до запуска. В лекции отдельно обсуждаются бесполезные выражения, случайное присваивание вместо сравнения и подозрительные конверсии.
-Werror превращает включённые предупреждения в ошибки сборки.
| Флаг | Назначение |
|---|---|
-Wall | Широкий базовый набор предупреждений. Название не означает буквально все возможные. |
-Wextra | Дополнительный набор предупреждений поверх базовых. |
-Wpedantic | Предупреждает о ряде расширений, нарушающих выбранный ISO-стандарт C++. |
-Werror | Считает включённые warnings ошибками компиляции. |
-Wno-unused-value | Выключает один конкретный класс предупреждений (пример, не универсальная рекомендация). |
-Werror=unused-value | Делает выбранный вид предупреждения ошибкой. |
Флаг -Werror не добавляет новые правила C++ и не превращает компилятор в доказательство правильности программы. Он задаёт более строгую политику сборки: выбранные предупреждения необходимо устранить, прежде чем появится новый исполняемый файл. Если сообщение непонятно, лучше уменьшить пример и выяснить причину, чем сразу выключать соответствующий класс предупреждений для всего проекта.
Учебная симуляция: точные категории предупреждений и умолчания зависят от компилятора и его версии.
int main() {
int x = 1;
// if (x = 0) {} // допустимо, но подозрительно
// x + 5; // результат выражения не используется
if (x == 0) {} // сравнение вместо присваивания
}Не стоит отключать все предупреждения ради успешной сборки. Сначала исправляй код. Целенаправленное подавление уместно, когда причина полностью понятна.
Warning (предупреждение) — Диагностическое сообщение компилятора о потенциальной проблеме. Само по себе не требует отклонить программу.
Практика диагностики
Отладка становится спокойнее, когда у вас есть воспроизводимый пример: конкретная версия исходника, команда сборки, входные данные и ожидаемый результат. Это позволяет менять по одной причине за раз и понимать, что повлияло на поведение. Фраза «иногда ломается» менее полезна, чем маленький пример, который показывает проблему при каждом повторении.
Надёжная базовая команда
Для учебных программ удобно явно указывать стандарт языка и включать основные предупреждения. С флагом -Werror Важно о -Wall-Wall включает типичный базовый набор предупреждений, а не все доступные проверки. Нередко стоит добавить -Wextra и -Wpedantic. Конкретные warnings зависят от GCC или Clang.
При отладке помогает сборка с символами -g; режим -O2 полезен для оптимизированной версии, но меняет отладочный опыт.
Уменьшайте программу постепенно: уберите несущественный вывод, сократите набор данных, оставьте только подозрительную операцию. После каждого изменения проверяйте, сохранилась ли проблема. Если ошибка исчезла, последнее удалённое действие может подсказать, какие условия были важны. Для границ контейнера, например, полезнее три элемента и один неверный индекс, чем большой файл с тысячами несвязанных операций.
# Собрать учебный пример на C++20
# g++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -g main.cpp -o app
# ./appЦенность строгой компиляции в том, что проблемы обнаруживаются заранее. Однако успешная компиляция не доказывает отсутствия UB: компилятор обязан диагностировать не все некорректные случаи.
Diagnostics (диагностика) — Сообщения компилятора и инструментов о возможных или обязательных ошибках, с указанием места и часто предлагаемого исправления.
Разобранный пример и самостоятельная проверка
Начните отладку с точного вопроса: какой элемент требуется прочитать и какие индексы существуют? Вектор values содержит три значения, поэтому допустимы номера ноль, один и два. Если запрос равен размеру, подходящего элемента нет. Не нужно запускать чтение за границей и ждать падения: исправление состоит в проверке условия до обращения. В безопасной программе плохой доступ оставлен только комментарием, а выполняемый код сообщает об ошибочном индексе. Сначала запишите ожидаемый исход для каждого теста, затем запускайте программу. Проверка индекса один должна дать двадцать; индекса три — отказ. Если защита случайно использует знак больше вместо больше или равно, именно второй тест обнаружит ошибку. Предупреждения компилятора помогают, но не доказывают, что проверка написана правильно. Тест следует сохранить после исправления, чтобы та же граница не сломалась при следующем изменении программы.
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
const std::vector<int> values{10, 20, 30};
const std::size_t index = 3;
// values[index]; // Не выполнять при index >= values.size().
if (index >= values.size()) {
std::cerr << "Invalid index\n";
return 1;
}
std::cout << values[index] << '\n';
}Один законченный сеанс исправления
- Симптом: для трёх элементов код пытается прочитать индекс 3. Ожидается отказ.
- Гипотеза: неверная граница проверки. Плохую операцию не выполняем.
- Минимальный вход: три значения и индекс 3; обычный контроль — индекс 1.
- Исправление: до доступа проверить index >= values.size().
- Повторный тест: 0, size−1 и size; затем пустой набор.
Санитайзеры: g++ -std=c++20 -g -O1 -fsanitize=address,undefined -fno-omit-frame-pointer main.cpp -o app. Они помогают на выполненных путях, но не доказывают отсутствие всех UB и не гарантируют обнаружение любого неинициализированного чтения.
Как компилятор понимает код
Сообщение компилятора — это попытка объяснить, на каком правиле языка остановился разбор программы. Он не знает вашу задумку, поэтому описывает фактическую запись. Например, пропущенная скобка может изменить структуру всего последующего текста. Тогда ошибка будет отмечена ниже настоящей причины, а исправление первой проблемы уберёт сразу несколько следующих сообщений.
Компилятор должен сперва распознать токены Токен и лексемаidentifier, integer literal, оператор). Лексема — фактически написанные символы, например count или 123. На начальном уровне оба слова часто используют почти как синонимы.
Лексический анализ
Разбивает поток символов на токены — идентификаторы, ключевые слова, операторы, скобки, литералы.
Например, int x = 5; можно представить как пять токенов.
Пробелы отделяют части текста, но сами обычно не становятся токенами.
Синтаксический анализ
Проверяет, можно ли собрать из токенов конструкцию по грамматике C++.
У x = 5 + ; после бинарного плюса отсутствует правое выражение.
Семантический анализ
Конструкция синтаксически понятна, но компилятор проверяет, допустима ли операция для этих типов, объявлены ли имена, однозначна ли перегрузка.
std::string s; s * 3; — понятное выражение, но нужного умножения нет.
std::string × intДля std::string нет подходящего оператора умноженияНачинайте с первого содержательного сообщения и смотрите не только на выделенную строку, но и немного выше. Спросите себя: узнаваемы ли отдельные символы и токены, правильно ли собрана конструкция, допустимы ли операции для данных типов? Такое разделение не обязано точно повторять устройство конкретного компилятора, но помогает превратить длинный текст диагностики в несколько проверяемых вопросов.
Диагностику компилятора проще разбирать на небольшой программе с одной причиной ошибки. Рабочие версии приведены ниже; строки с ошибками оставлены в комментариях. Включайте их по одной и начинайте чтение с первого сообщения компилятора.
На этом шаге лексер ещё не проверяет значения переменных.
#include <iostream>
int main() {
int total = 12;
// std::cout << totla; // Error: undeclared name.
std::cout << total << '\n';
}#include <iostream>
int square(int value);
int main() {
std::cout << square(6) << '\n';
}
int square(int value) {
return value * value;
}#include <string>
int main() {
int x = 3;
// x = 5 + ; // синтаксическая ошибка
// std::string s = "abc";
// auto y = s * 3; // семантическая ошибка
// unknown_name = 1; // имя не объявлено
return x;
}Сообщения компилятора не обязаны дословно совпадать между GCC и Clang. У них могут отличаться и формулировки, и точное место указания ошибки.
Токен (token) — Минимальная единица, которую лексер распознал в исходном тексте: идентификатор, оператор, литерал или знак пунктуации.
Семантическая ошибка (semantic error) — Код удаётся разобрать по грамматике, но действие невозможно по правилам языка или недопустимо для указанных сущностей.
Ошибки во время выполнения
Во время выполнения программа получает конкретные значения, которых могло не быть в ваших первых проверках. Массив оказывается пустым, индекс — слишком большим, делитель — нулём. Поэтому проверять следует не только «обычный» пример, но и границы допустимых данных. Важен сам нарушенный договор операции (какие условия должны были выполняться), а не только название сообщения, показанного операционной системой.
Runtime error — общее название проблем, возникающих после запуска. ОС и стандартная библиотека могут завершить программу, но механизм зависит от причины и платформы.
Segmentation fault Почему не каждый выход за границы вызывает segfault?Защита памяти обычно работает с более крупными областями — страницами. Обращение к чужому элементу может попасть в отображённую страницу и внешне «сработать», хотя по стандарту уже произошло UB.
На системах с защитой памяти возникает при недопустимом обращении к адресному пространству: например, при обращении к неотображённой странице или попытке записи в память только для чтения.
Выход за границы vector через [] — UB, и segfault лишь один из возможных эффектов.
v[50000] может попасть в недоступную память — но падение не гарантировано.Integer division by zero
int a = 5 / x; при x == 0 — UB в C++. На типичных Linux/x86 системах такой код может завершиться сигналом SIGFPE с надписью Floating point exception.
Название сигнала историческое: он может быть вызван и целочисленным делением.
Необработанное исключение
v.at(i) проверяет границы. При неверном индексе метод выбрасывает std::out_of_range.
Если исключение не перехватить, вызывается std::terminate. Его стандартный обработчик обычно вызывает std::abort.
v[i]За границей — UB; ошибка не обязана обнаружитьсяv.at(i)За границей — исключение std::out_of_range#include <vector>
#include <stdexcept>
#include <iostream>
int main() {
std::vector<int> v = {10, 20, 30};
// v[3] = 5; // UB: вне диапазона
try {
std::cout << v.at(3);
} catch (const std::out_of_range&) {
std::cout << "Неверный индекс";
}
}Сравните обращение values.at(index) с values[index]. В первом случае ошибка индекса сообщает о себе через предусмотренное библиотекой исключение, которое можно обработать. Во втором выход за допустимые границы не превращается автоматически в такое же исключение. Если программа «случайно не упала», это ещё не делает чтение корректным: нужно проверить индекс до обращения или выбрать операцию с проверкой.
Успешная компиляция не гарантирует, что данные подходят программе. Проверяйте внешние значения и границы контейнеров в местах использования. Во вкладках показаны два способа обработать ошибку и продолжить работу предсказуемо.
Размер равен 3, индексы 0, 1, 2.
#include <iostream>
#include <stdexcept>
#include <vector>
int main() {
std::vector<int> values{10, 20};
try {
std::cout << values.at(4) << '\n';
} catch (const std::out_of_range&) {
std::cout << "Invalid index\n";
}
}#include <iostream>
#include <sstream>
int main() {
std::istringstream input{"-3"};
int count = 0;
if (!(input >> count) || count < 0) {
std::cout << "Expected a nonnegative count\n";
return 0;
}
std::cout << "Count: " << count << '\n';
}Segmentation fault (SIGSEGV) — Сигнал о нарушении доступа к памяти на соответствующих ОС. Не тождественен всякому выходу за границы массива.
Исключение (exception) — Механизм передачи управления обработчику ошибки через throw/catch. Не равно сигналу SIGFPE.
Неопределённое поведение
UB трудно изучать по принципу «сначала запущу и посмотрю, что получится». Для корректной программы наблюдение помогает проверить результат, а при неопределённом поведении сами гарантии, на которые вы рассчитываете, уже нарушены. Поэтому главный вопрос здесь — не какое число сегодня напечаталось, а не произошло ли действие, запрещённое правилами языка или условиями библиотечной операции.
Если программа попадает в ситуацию, для которой стандарт C++ не предъявляет требований к поведению, дальнейший результат не гарантируется. Даже если программа «работала вчера», это не доказательство корректности.
Главная ловушка: ожидать падения
UB не обязано приводить к RE. Оно может выдавать правдоподобные числа, оставаться незаметным годами, менять результат после обновления компилятора или проявляться только при -O2.
Смысл прост: компилятор оптимизирует корректные программы, исходя из того, что нарушений требований языка нет.
| Пример | Почему UB | Возможный эффект |
|---|---|---|
v[v.size()] | Обращение за последним элементом через [] | Произвольное значение, падение или иное |
int x; cout << x; | Чтение неинициализированного int (обычные случаи в контексте лекции) | Неопределённый результат |
INT_MAX + 1 | Переполнение знакового int | Оптимизатор может изменить логику |
i++ + i++ | Изменения i не упорядочены | Никакой гарантии значения |
Для беззнаковых целочисленных типов Как устроено переполнение unsigned?N битов, арифметика с беззнаковым результатом ведётся по модулю 2^N. Например, для 8-битного unsigned char значение 255 + 1 после приведения к этому типу даст 0.2^N. Для знаковых целых — UB. Не переноси правило unsigned на обычный int.
Например, у вектора с тремя элементами допустимы обычные индексы от нуля до двух. Обращение к четвёртому элементу не становится безопасным оттого, что рядом с буфером оказалась доступная память. Чтобы исправить программу, нужно восстановить правильную границу обхода или изменить логику хранения. Перехватывать только видимое падение недостаточно: некорректное действие могло остаться незаметным.
#include <climits>
#include <vector>
void demo() {
int x = INT_MAX;
// ++x; // UB: signed overflow
int i = 0;
// int z = i++ + i++; // UB: неупорядоченные изменения
std::vector<int> v(3);
// int a = v[3]; // UB: индекс вне границ
}Undefined Behavior (UB) — Отсутствие требований стандарта к поведению при выполнении определённого некорректного действия; результат нельзя обосновывать наблюдением одного запуска.
Другие категории поведения
Не всякий результат, отличающийся на двух компьютерах, означает неопределённое поведение. Иногда стандарт специально оставляет реализации выбор из нескольких вариантов. Важно понять границы этого выбора: разрешено ли несколько конкретных исходов, обязан ли компилятор документировать решение или программа вообще нарушила требования языка. Эти ситуации требуют разных выводов о переносимости кода.
Если стандарт допускает несколько ограниченных исходов, это ещё не UB. Нужно различать unspecified, implementation-defined и ill-formed, no diagnostic required.
Если порядок двух допустимых независимых действий не указан, нельзя требовать один заранее выбранный порядок, но и говорить «разрешено абсолютно всё» неверно. Если свойство определяется реализацией, его можно узнать из документации используемых инструментов и платформы. А при UB документация об одном наблюдаемом запуске не заменяет исправления программы. Сначала назовите категорию, затем решайте, на что можно опираться.
int x = INT_MAX; ++x;| Категория | Что гарантировано | Пример |
|---|---|---|
Undefined behavior | Гарантий для поведения не устанавливается | Знаковое переполнение |
Unspecified behavior | Возможен один из разрешённых вариантов; реализация не обязана заранее сообщать выбор | Порядок вычисления независимых аргументов функции |
Implementation-defined | Реализация выбирает допустимый вариант и документирует его | Размер int на конкретной платформе |
IFNDR | Программа некорректна, но реализация не обязана выдавать диагностику | Некоторые нарушения ODR между единицами трансляции |
Для f(a(), b()) в C++17 порядок аргументов не указан, но вычисления одного аргумента не перемешиваются с другим. Сам по себе такой вызов — не UB.
Unspecified Behavior (неуказанное поведение) — Стандарт разрешает несколько определённых вариантов поведения, не требуя документировать конкретный выбор.
Implementation-defined Behavior (поведение, определяемое реализацией) — Реализация выбирает и документирует один из разрешённых стандартом вариантов.
IFNDR (ill-formed, no diagnostic required) — Нарушение правила языка, которое реализация не обязана диагностировать. Важно не рассчитывать на корректную работу даже после успешной сборки.
Почему UB меняет оптимизацию
Оптимизатор не обязан механически переводить каждую строку исходника в отдельную машинную команду. Он может сокращать работу, используя свойства языка и типов. Это нормально для корректной программы: внутренние шаги меняются, а требуемый наблюдаемый результат сохраняется. Проблема появляется, когда программист рассчитывает на поведение, которое стандарт для его кода не обещает.
Компилятор вправе рассуждать так: «Если корректная программа не переполняет int, то определённые условия всегда истинны». Это может убрать проверки, на которые программист рассчитывал при переполнении.
Опасно сначала вычислить сумму знаковых целых с переполнением и только потом пытаться проверить, «не стала ли она неправильной». Нарушение уже произошло в момент сложения. Надёжная проверка сравнивает операнды с границами типа заранее либо использует подходящий механизм контролируемой арифметики. Отключение оптимизации может изменить симптомы, но само по себе не исправляет причину ошибки.
Пример из лекции: i * 12345678
При типичном 32-битном int максимальное значение — 2 147 483 647. Пока i ≤ 173, умножение помещается. При i = 174 происходит знаковое переполнение.
Без оптимизации можно случайно увидеть отрицательное число. При -O2 в конкретном примере лекции исчезла проверка, и цикл стал бесконечным.
Знаковое переполнение нельзя надёжно обнаружить сравнением уже вычисленной суммы: ошибка произошла бы раньше проверки. Сначала сравните операнды с границами типа. Беззнаковая арифметика имеет определённый переход через границу, но он тоже может нарушить логику задачи.
#include <iostream>
#include <limits>
bool add(int left, int right, int& result) {
const int maximum = std::numeric_limits<int>::max();
const int minimum = std::numeric_limits<int>::min();
if ((right > 0 && left > maximum - right) ||
(right < 0 && left < minimum - right)) {
return false;
}
result = left + right;
return true;
}
int main() {
int result = 0;
if (!add(std::numeric_limits<int>::max(), 1, result)) {
std::cout << "Overflow prevented\n";
}
if (add(20, 22, result)) {
std::cout << result << '\n';
}
}#include <iostream>
#include <limits>
int main() {
unsigned int count = 0;
--count;
bool at_maximum = count == std::numeric_limits<unsigned int>::max();
std::cout << std::boolalpha << at_maximum << '\n';
}#include <iostream>
#include <limits>
int main() {
for (int i = 173; i <= 174; ++i) {
const long long product = 1LL * i * 12'345'678;
std::cout << i << ": " << product;
if (product > std::numeric_limits<int>::max()) {
std::cout << " outside int";
}
std::cout << '\n';
}
// int overflow = 174 * 12'345'678; // Не выполнять: UB для 32-bit int.
}inti ≤ 173i < 300 гарантированно истинноЭто объяснение возможной оптимизации, а не обещание, что любой компилятор обязательно создаст бесконечный цикл.
Правило as-if Что считается наблюдаемым поведением?Это не просто «всё, что мы видим на экране»: учитываются требования абстрактной машины C++, в том числе наблюдаемые операции ввода-вывода и доступы к volatile-объектам. Ненаблюдаемые промежуточные шаги оптимизатор может менять.
Оптимизатор может перестраивать программу как угодно, если для корректного кода сохраняется предписанное стандартом наблюдаемое поведение.
Именно поэтому исходный порядок инструкций не всегда совпадает с машинным. Но вызовы ввода-вывода и другие наблюдаемые эффекты нельзя менять произвольно.
std::cout << 12;std::cout << 12;Оптимизация (optimization) — Преобразование кода с целью эффективности при сохранении требуемого наблюдаемого поведения корректной программы.
As-if rule (правило «как если бы») — Разрешает реализации разные внутренние преобразования, пока наблюдаемое поведение корректной программы остаётся допустимым.
Дополнительно
Санитайзеры: поиск ошибок во время запуска
В самой лекции санитайзеры только упоминаются как будущая тема. Это дополнительная полезная практика: AddressSanitizer помогает обнаруживать некоторые ошибки доступа к памяти, UndefinedBehaviorSanitizer — ряд вариантов UB.
# Дополнительная проверка для отладки (GCC / Clang)
# g++ -std=c++20 -g -O1 -fsanitize=address,undefined \
# -fno-omit-frame-pointer main.cpp -o app
# ./appНи один санитайзер не гарантирует обнаружение всех UB. Инструменты могут пропускать ошибки, особенно если соответствующий путь исполнения не был проверен.
Разбор вывода предупреждений
file.cpp:12:5 показывает файл, строку и столбец; warning: обозначает категорию диагностики; [-Wunused-value] — имя конкретного класса предупреждений GCC/Clang.
Тонкости std::abort
Исключение std::out_of_range, выброшенное vector::at, можно перехватить с помощью catch. Если оно остаётся необработанным, вызывается std::terminate, и стандартный обработчик обычно завершает процесс через abort. Нельзя считать это неизбежным выводом «Aborted» во всех окружениях.
Почему нельзя судить об UB по одному запуску?
Исполняемый код зависит от компилятора, версии, флагов и платформы. «У меня сработало» не является гарантией стандарта языка.
Термины
Исходный код не проходит обязательные проверки компиляции.
Примеры кода
Диагностика до опасного доступа
#include <iostream>
#include <vector>
int main() {
const std::vector<int> values{10, 20, 30};
int index = 0;
if (!(std::cin >> index) || index < 0 ||
static_cast<std::size_t>(index) >= values.size()) {
std::cerr << "Invalid index\n";
return 1;
}
std::cout << values[static_cast<std::size_t>(index)] << '\n';
}Безопасный отказ до доступа: Invalid index; код завершения 1.
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
const std::vector<int> values{10, 20, 30};
const std::size_t index = 3;
// values[index]; // Не выполнять при index >= values.size().
if (index >= values.size()) {
std::cerr << "Invalid index\n";
return 1;
}
std::cout << values[index] << '\n';
}Обсудить лекцию с ИИ
Скопируйте промпт в свой ИИ-чат. В него уже включены материал этой лекции и примеры кода — можно сразу задавать вопросы и тренироваться.