Перейти к содержимому

Понимание и избежание неопределённого поведения

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

Что такое неопределённое поведение и зачем оно существует?

Неопределённое поведение (UB) означает, что стандарт C++ не предъявляет никаких требований к поведению программы. Программа может аварийно завершиться, выдать неправильные результаты, казаться работающей правильно или отформатировать ваш жёсткий диск — всё это «валидные» исходы.

UB существует потому, что позволяет агрессивную оптимизацию. Когда компилятор знает, что определённые ситуации «не могут произойти» (потому что они были бы UB), он может генерировать более быстрый код, опуская проверки для этих ситуаций. Например:

- Переполнение знаковых целых — UB позволяет компилятору считать, что x + 1 > x всегда истинно, что делает возможными оптимизации циклов.
- Разыменование нулевого указателя — UB позволяет компилятору исключить проверки на null после разыменования.
- Доступ к памяти за пределами — UB позволяет компилятору опустить проверки границ, делая доступ к массивам таким же быстрым, как обращение к «сырой» памяти.

Фундаментальная сделка: C++ доверяет программисту избегать UB в обмен на максимальную производительность. Когда это доверие нарушается, все ставки сняты.

Распространённые источники неопределённого поведения

Вот наиболее частые источники UB в реальном коде C++. Каждый программист C++ сталкивается с ними.

common_ub.cpp
#include <vector>
#include <iostream>
#include <cstdint>

// 1. SIGNED INTEGER OVERFLOW
void signed_overflow() {
    int x = INT32_MAX;
    int y = x + 1;        // UB! Signed overflow is undefined
    // unsigned overflow is well-defined (wraps around)
    unsigned u = UINT32_MAX;
    unsigned v = u + 1;   // OK: v == 0 (well-defined wrap)
}

// 2. NULL POINTER DEREFERENCE
void null_deref(int* ptr) {
    int val = *ptr;       // UB if ptr is null
    if (ptr == nullptr) { // Compiler may remove this check!
        // The compiler reasons: "ptr was already dereferenced above,
        // which would be UB if null. Since UB cannot happen in a
        // correct program, ptr must be non-null. This check is dead code."
        std::cout << "null!\n";
    }
}

// 3. OUT-OF-BOUNDS ACCESS
void out_of_bounds() {
    int arr[5] = {1, 2, 3, 4, 5};
    int bad = arr[10];    // UB! No bounds checking on raw arrays
    // std::vector::operator[] is also UB if out of bounds
    // Use .at() for bounds-checked access (throws std::out_of_range)
}

// 4. USE-AFTER-FREE / DANGLING REFERENCES
int& dangling_reference() {
    int local = 42;
    return local;         // UB! Reference to destroyed local
}

// 5. DATA RACES
// int shared = 0;
// Thread 1: shared++;    // UB if concurrent with Thread 2
// Thread 2: shared++;    // UB! Data race on non-atomic variable

Проблема «путешествия во времени»

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

Рассмотрим:

bool checked = false;
void process(int* p) {
    int value = *p;        // UB, если p — null
    checked = true;        // Эта строка ПЕРЕД использованием 'value'
    if (p == nullptr) {    // Компилятор: "p был разыменован, значит, он не null"
        abort();           // Компилятор: "Это мёртвый код, удалить"
    }
    use(value);
}

Компилятор может установить checked = true до разыменования (переупорядочение для конвейера) и затем полностью удалить проверку на null. Результат: checked устанавливается в true даже когда p равен null, и разыменование null продолжается без abort. UB «путешествовало назад во времени», чтобы испортить флаг checked.

Это не ошибка компилятора — это прямое следствие свободы оптимизации, которую предоставляет UB.

Обнаружение UB с помощью санитайзеров

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

sanitizers.sh
# AddressSanitizer (ASan) — detects:
#   - Buffer overflow (stack, heap, global)
#   - Use-after-free, use-after-return
#   - Double-free, memory leaks
g++ -fsanitize=address -fno-omit-frame-pointer -g -O1 program.cpp -o program

# UndefinedBehaviorSanitizer (UBSan) — detects:
#   - Signed integer overflow
#   - Null pointer dereference
#   - Out-of-bounds array access
#   - Misaligned memory access
#   - Type punning violations
g++ -fsanitize=undefined -fno-omit-frame-pointer -g program.cpp -o program

# ThreadSanitizer (TSan) — detects:
#   - Data races between threads
#   - Lock order inversions (potential deadlocks)
g++ -fsanitize=thread -g program.cpp -o program

# MemorySanitizer (MSan) — detects:
#   - Reads of uninitialized memory
#   (Clang-only, not available in GCC)
clang++ -fsanitize=memory -fno-omit-frame-pointer -g program.cpp -o program

# Combine ASan + UBSan (common in CI pipelines)
g++ -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1 \
    -Wall -Wextra -Werror program.cpp -o program

# CMake integration
# In CMakeLists.txt:
# target_compile_options(myapp PRIVATE -fsanitize=address,undefined)
# target_link_options(myapp PRIVATE -fsanitize=address,undefined)

Предупреждения компилятора как сеть безопасности

Современные компиляторы могут обнаруживать многие потенциальные ситуации UB во время компиляции. Рассматривайте предупреждения как ошибки (-Werror) в CI, чтобы гарантировать, что ни одно предупреждение не проигнорировано.

warnings.sh
# Recommended warning flags for all C++ projects
g++ -std=c++23 \
    -Wall           # Enable most warnings \
    -Wextra         # Enable additional warnings \
    -Wpedantic      # Strict ISO C++ compliance \
    -Werror         # Treat warnings as errors \
    -Wconversion    # Warn on implicit narrowing conversions \
    -Wshadow        # Warn when a variable shadows another \
    -Wnon-virtual-dtor  # Warn on classes with virtual functions but non-virtual dtor \
    -Wold-style-cast    # Warn on C-style casts \
    -Wcast-align    # Warn on potentially misaligned casts \
    -Wunused        # Warn on unused variables/functions \
    -Woverloaded-virtual  # Warn when a derived class hides a base virtual function \
    -Wnull-dereference   # Warn on potential null dereferences (GCC) \
    -Wdouble-promotion   # Warn when float is implicitly promoted to double \
    -Wformat=2      # Warn on printf/scanf format string issues \
    program.cpp -o program

# For Clang, add:
#   -Weverything    # Enable ALL warnings (then selectively disable)
#   -Wno-c++98-compat  # Disable C++98 compatibility warnings
Лучшая практика

Следуйте этим рекомендациям для минимизации UB в вашей кодовой базе.

  • Включите все предупреждения и рассматривайте их как ошибки: -Wall -Wextra -Wpedantic -Werror
  • Запускайте санитайзеры в CI: комбинируйте ASan + UBSan для каждого прогона тестов; TSan для многопоточного кода
  • Используйте .at() вместо [] для доступа к контейнерам с проверкой границ во время разработки
  • Используйте умные указатели (unique_ptr, shared_ptr) вместо «сырых» new/delete для устранения use-after-free
  • Используйте std::atomic для переменных, разделяемых между потоками — никогда не используйте обычные типы для межпоточных данных
  • Предпочитайте беззнаковые типы для битовых операций, но знаковые типы для арифметики (беззнаковая арифметика оборачивается, что может вызвать неочевидные ошибки в границах циклов)
Подводный камень

Самое опасное UB — код, который прекрасно работает при тестировании, но ломается в продакшене. Вот почему.

  • UB может «работать» годами и сломаться при обновлении компилятора, изменении уровня оптимизации или запуске на другом процессоре
  • Переполнение знаковых в циклах: for (int i = 0; i < n; i++) — если n равно INT_MAX, i++ переполняется. Компилятор может предположить, что цикл завершается, и оптимизировать соответственно
  • Чтение неинициализированных переменных: значение не «случайное» — компилятор может полностью оптимизировать переменную или распространить какие угодно биты, оказавшиеся в регистре
  • Нарушения строгого алиасинга: приведение int* к float* и разыменование — UB (кроме через char* или std::byte*). Используйте std::bit_cast (C++20) для преобразования типов
  • std::string_view, переживший строку: возвращает висячее представление, которое, кажется, работает, пока память не будет переиспользована
Ключевые выводы
  • Неопределённое поведение существует для обеспечения агрессивных оптимизаций компилятора — C++ обменивает безопасность на производительность
  • UB может «путешествовать во времени» — компилятор может переупорядочить или исключить код, предшествующий точке UB
  • Наиболее распространённые источники UB — переполнение знаковых, разыменование null, выход за границы, use-after-free и гонки данных
  • Санитайзеры (ASan, UBSan, TSan, MSan) обнаруживают UB во время выполнения — запускайте их в каждом CI-конвейере
  • Предупреждения компилятора (-Wall -Wextra -Werror) ловят многие потенциальные ситуации UB во время компиляции
  • UB, которое «работает» сегодня, может тихо сломаться с новым компилятором, уровнем оптимизации или платформой

Тест — проверьте свои знания

(15 XP)

1. Почему переполнение знаковых целых является неопределённым поведением в C++?

2. Что обнаруживает AddressSanitizer (ASan)?

3. Что такое проблема «путешествия во времени» с неопределённым поведением?