Неопределённое поведение¶
Неопределённое поведение (англ. undefined behavior) — свойство некоторых языков программирования (прежде всего C и C++), при котором результат выполнения программы, содержащей определённые конструкции или нарушающей определённые правила, не регламентируется стандартом языка. В таких случаях программа может вести себя любым образом: от корректной работы до аварийного завершения, выдачи непредсказуемых результатов или выполнения несанкционированных операций. Стандарт языка сознательно оставляет такие ситуации неопределёнными, чтобы не накладывать избыточных ограничений на реализации (компиляторы, интерпретаторы), позволяя им генерировать более эффективный код.
¶История
Концепция неопределённого поведения возникла в ранних версиях языка C (K&R C, 1978 год) и была формализована в стандарте ANSI C (C89/C90). Разработчики языка решили, что не все ошибки времени выполнения должны обязательно обнаруживаться и обрабатываться — это позволило создавать более быстрые программы для систем с ограниченными ресурсами. В стандарте C++ (первый — C++98) эта концепция была унаследована и расширена.
В последующие версии стандартов (C99, C11, C17, C23; C++11, C++14, C++17, C++20, C++23) списки ситуаций с неопределённым поведением уточнялись и дополнялись. Например, в C++11 было введено понятие «неопределённое поведение при многопоточном доступе к данным без синхронизации» (гонка данных). В C23 и C++23 были добавлены новые категории, связанные с безопасностью памяти.
¶Классификация
В стандартах C и C++ выделяют три основные категории поведения, помимо строго определённого:
- Определённое реализацией (implementation-defined behavior) — результат зависит от конкретной реализации (компилятора, платформы), но должен быть задокументирован. Пример: размер типа
int(обычно 4 байта на 32-битных системах). - Неуточнённое поведение (unspecified behavior) — результат может варьироваться между разными вызовами одной и той же программы, но не является неопределённым. Пример: порядок вычисления аргументов функции (слева направо или справа налево).
- Неопределённое поведение (undefined behavior) — стандарт не накладывает никаких требований. Пример: разыменование нулевого указателя.
Также существует категория поведения, зависящего от локали (locale-specific behavior), но она редко упоминается в контексте неопределённого поведения.
¶Примеры неопределённого поведения в C и C++
¶Разыменование нулевого указателя
``c int p = NULL; p = 42; // неопределённое поведение `` На многих системах это приводит к segmentation fault, но стандарт не гарантирует этого.
¶Выход за границы массива
``c int arr[5]; arr[10] = 0; // неопределённое поведение `` Программа может испортить память, вызвать сбой или работать «как ни в чём не бывало».
¶Деление на ноль
``c int a = 5 / 0; // неопределённое поведение `` В отличие от чисел с плавающей точкой, где деление на ноль даёт бесконечность или NaN, для целых чисел это неопределённое поведение.
¶Изменение строкового литерала
``c char *s = "hello"; s[0] = 'H'; // неопределённое поведение (строковые литералы часто размещаются в read-only памяти) ``
¶Использование значения неинициализированной переменной
``c int x; int y = x + 1; // неопределённое поведение, если x не инициализирована `` Компилятор может оптимизировать код, исходя из предположения, что переменная инициализирована, что приводит к непредсказуемым результатам.
¶Переполнение знаковых целых чисел
``c int a = INT_MAX; a++; // неопределённое поведение для знаковых типов `` Для беззнаковых типов переполнение определено (обёртка по модулю 2^n).
¶Гонка данных (data race) в многопоточных программах
``c int shared = 0; // поток 1: shared++; // поток 2: shared++; // без мьютекса — неопределённое поведение ``
¶Неопределённое поведение в других языках
Хотя термин чаще всего ассоциируется с C и C++, другие языки также имеют концепции неопределённого поведения, хотя и в меньшей степени:
- Rust: использует концепцию «неопределённого поведения» в unsafe-блоках. В безопасном коде Rust гарантирует отсутствие неопределённого поведения.
- C#: в unsafe-контексте может возникать неопределённое поведение при работе с указателями. В безопасном коде его нет.
- Java: спецификация определяет строгое поведение для всех конструкций, но некоторые операции (например, деление на ноль для целых чисел) генерируют исключение, что является определённым поведением.
- Python: неопределённое поведение практически отсутствует, все ошибки приводят к исключениям.
¶Опасности и критика
Неопределённое поведение считается одной из главных причин уязвимостей в программах на C и C++. Компиляторы могут оптимизировать код, исходя из предположения, что неопределённое поведение никогда не происходит. Например:
``c int foo(int p) { if (p) return p; else return 0; } ` Компилятор может удалить проверку if (p), так как разыменование нулевого указателя — неопределённое поведение, и компилятор считает, что p` никогда не может быть нулевым. Это приводит к «исчезновению» проверок, которые программист явно написал.
Известные уязвимости, связанные с неопределённым поведением:
- Heartbleed (2014, OpenSSL) — чтение за границами буфера (выход за границы массива).
- Goto fail (2014, Apple) — ошибка в проверке сертификатов, связанная с неопределённым поведением при использовании
goto. - CVE-2021-42574 — уязвимость в компиляторах, связанная с неопределённым поведением при обработке Unicode (троянские источники).
Критики утверждают, что неопределённое поведение делает C и C++ непригодными для написания безопасного кода. В ответ на это были разработаны инструменты статического и динамического анализа (например, AddressSanitizer, UndefinedBehaviorSanitizer), которые помогают выявлять такие ситуации на этапе разработки.
¶Способы предотвращения
- Использование статических анализаторов (Clang Static Analyzer, PVS-Studio, Coverity).
- Динамические анализаторы (AddressSanitizer, UBSan, Valgrind).
- Строгие флаги компиляции (например,
-Wall -Wextra -Werrorв GCC/Clang). - Написание кода с явными проверками границ и инициализацией переменных.
- Применение safe-библиотек (например,
std::spanв C++20 вместо сырых указателей). - Использование языков с безопасной памятью (Rust, Go, Java) для критически важных систем.
¶Интересные факты
- В стандарте C11 насчитывается около 200 ситуаций с неопределённым поведением.
- Некоторые компиляторы (например, GCC) используют неопределённое поведение для агрессивных оптимизаций: если программа содержит UB, компилятор может удалить целые блоки кода.
- Существует шуточное «правило»: «неопределённое поведение может сделать так, что демоны вылетят из носа программиста» (nasal demons) — отсылка к гипотетическому крайнему случаю, описанному в Usenet.
- В стандарте C++23 была добавлена концепция «неопределённого поведения при доступе к памяти через указатель на объект, время жизни которого закончилось».
¶Источники
- ISO/IEC 9899:2018 (C17) — раздел 3.4.3 «undefined behavior».
- ISO/IEC 14882:2020 (C++20) — раздел 3.28 «undefined behavior».
- «Expert C Programming: Deep C Secrets» by Peter van der Linden.
- «Effective C++» by Scott Meyers.
- Документация GCC и Clang по флагам
-fsanitize=undefined. - Статья «What Every C Programmer Should Know About Undefined Behavior» на LLVM Blog.
BFOmetr — база данных и аналитика по компаниям России.
На главную BFOmetr →


