Оптимизации компилятора в C++: что ревьюить на самом деле
Компилятор выбрасывает половину кода, переставляет операции и удаляет проверки на переполнение. Разбираем, какие места в C++ ломаются от оптимизаций и что искать глазами на ревью.

Функция обнуляет буфер с паролем через memset, компилируется в -O2 — и в дизассемблере обнуления нет. Компилятор доказал, что буфер больше не читается, и выкинул запись как мёртвую. Это не баг компилятора, это разрешённое поведение по стандарту. Ревьюер, который читает только логику, такое не поймает: код правильный, но при -O2 делает не то, что написано.
Оптимизации GCC и Clang меняют наблюдаемое поведение программы ровно там, где вы полагаетесь на неопределённое поведение (UB) или на порядок, который стандарт не гарантирует. На ревью нельзя проверить корректность оптимизаций — их писали годами. Можно проверить, не даёте ли вы компилятору право сломать вашу программу. Разбираем конкретные места, куда смотреть. Речь про GCC 13+ и Clang 17+, режимы -O2/-O3; для более старых версий детали отличаются, но принципы те же.
Почему логически верный код ведёт себя иначе под оптимизацией
Компилятор оптимизирует, исходя из аксиомы: программа не содержит UB. Если в коде есть неопределённое поведение, компилятор вправе считать, что этот путь недостижим, и удалять зависящие от него проверки. Классика — знаковое переполнение.
// GCC 13, -O2
// Проверка после сложения бессмысленна для компилятора
bool overflows(int base, int delta) {
int sum = base + delta; // знаковое переполнение — это UB
return sum < base; // компилятор заменит на delta < 0
}Компилятор рассуждает: раз переполнение — UB, а UB не бывает, значит sum всегда корректно равно base + delta, и sum < base эквивалентно delta < 0. Проверка переполнения, ради которой всё писалось, исчезает. На ревью это ищется по паттерну «арифметика над знаковыми, а потом сравнение результата с операндом».
Компилятор не мстит за UB. Он предполагает, что UB нет, и строит выводы. Проблема не в оптимизации, а в том, что вы пообещали то, чего не выполняете.
Что искать на ревью: пять зон риска
1. Знаковое переполнение и проверки после арифметики
Любая ручная проверка переполнения знаковых типов через сам результат сложения или умножения ненадёжна. Замечание в ревью формулируйте конкретно: «Проверка переполнения через sum < base удаляется компилятором, потому что знаковое переполнение — UB. Используйте __builtin_add_overflow (GCC/Clang) или беззнаковую арифметику с явной проверкой».
2. memset для затирания секретов
Запись, результат которой нигде не читается, компилятор удаляет как мёртвую (dead store elimination). Обнуление буфера перед возвратом из функции — ровно такой случай. Замечание: «Этот memset будет выброшен при -O2. Для затирания секретов нужен memset_s (C11 Annex K, поддержка неполная), explicit_bzero (glibc, BSD) или барьер вроде SecureZeroMemory на Windows».
3. Разыменование указателя до проверки на nullptr
Если код разыменовывает указатель, а ниже сравнивает его с nullptr, компилятор считает: раз разыменование прошло без UB, указатель не равен nullptr — и удаляет проверку.
// Clang 17, -O2
int read_first(int* ptr) {
int value = *ptr; // если ptr == nullptr — это UB
if (ptr == nullptr) { // проверка удаляется: ptr уже "доказанно" не null
return -1;
}
return value;
}Замечание: «Проверка на nullptr стоит после разыменования и будет удалена. Проверяйте до первого доступа к объекту».
4. Строгий алиасинг (strict aliasing)
Чтение объекта одного типа через указатель на несовместимый тип — UB, и компилятор при -O2 (флаг -fstrict-aliasing включён) вправе переупорядочить или закешировать чтения. Приведение float* к uint32_t* для разбора битов — типичный источник тихих багов.
// Нарушает строгий алиасинг — поведение под -O2 не определено
uint32_t float_bits_bad(float value) {
return *reinterpret_cast<uint32_t*>(&value);
}
// Корректно: копирование байтов через memcpy
uint32_t float_bits_ok(float value) {
uint32_t result;
std::memcpy(&result, &value, sizeof(result));
return result;
}
// C++20 и новее: std::bit_cast
uint32_t float_bits_bitcast(float value) {
return std::bit_cast<uint32_t>(value); // требует <bit>
}Замечание: «Приведение через reinterpret_cast между несовместимыми типами нарушает строгий алиасинг. Используйте std::bit_cast (C++20) или std::memcpy — оба компилятор распознаёт и генерирует такой же код без UB».
5. Отсутствие volatile или атомиков там, где есть внешнее изменение
Переменная, которую меняет обработчик прерывания, DMA или другой поток, но которая читается в цикле без volatile или std::atomic, будет закеширована в регистре. Компилятор превратит while (!flag) {} в бесконечный цикл, прочитав flag один раз. Для межпоточного обмена volatile недостаточно — нужен std::atomic с корректным memory order.
Флаги, которые меняют картину на ревью
Прежде чем спорить о конкретной строке, посмотрите на флаги сборки в CI. Одни и те же исходники ведут себя по-разному.
| Флаг | Что делает | На что смотреть в коде |
|---|---|---|
-O2 / -O3 | Полный набор оптимизаций, включая удаление мёртвого кода и переупорядочивание | Все пять зон риска выше |
-fno-strict-aliasing | Отключает предположения об алиасинге | Если стоит — приведения типов безопаснее; если нет — проверяйте reinterpret_cast |
-fwrapv | Делает знаковое переполнение определённым (обёртка) | Если стоит — проверки переполнения через результат работают |
-ffast-math | Разрешает переассоциацию и игнор NaN/Inf в float | Код, полагающийся на порядок операций или проверки isnan |
-fsanitize=undefined | Ловит UB в рантайме (UBSan) | Не оптимизация, но лучший способ найти проблемы до прода |
Отдельно про -ffast-math: он ломает проверки вида if (x != x) для отлова NaN, потому что компилятор считает NaN невозможным. Если в коде есть работа с NaN/Inf, а сборка идёт с -ffast-math, это красный флаг.
Как проверять замечание, а не гадать
Не полагайтесь на «мне кажется, компилятор это выбросит». Проверяйте:
- Godbolt (Compiler Explorer) — вставьте функцию, выберите тот же компилятор и флаги, что в проде, и посмотрите ассемблер. Пропавшая проверка видна сразу.
- UBSan и ASan в CI — сборка тестов с
-fsanitize=undefined,addressловит большинство описанных UB на реальных данных. Если этого нет в пайплайне — это само по себе замечание к проекту. - Флаги предупреждений —
-Wall -Wextraплюс-Wstrict-aliasingи-Wmaybe-uninitializedподсвечивают часть проблем ещё на компиляции.
Граничные случаи: где само ревью бесполезно
Часть проблем глазами не ловится в принципе. Межтрансляционная оптимизация (LTO, -flto) переносит рассуждения о UB через границы файлов: функция в одном .cpp может влиять на удаление кода в другом. Ревью одного файла тут не помогает — нужен UBSan на интеграционных тестах.
Второй случай — код, который «работает» в отладочной сборке (-O0) и падает только в релизе. Если баг воспроизводится лишь под -O2, это почти всегда UB, а не ошибка оптимизатора. Ищите не в оптимизации, а в том, что программа обещала компилятору.
Что делать после ревью
Одноразовая вычитка не масштабируется. Настройте пайплайн так, чтобы UB ловился без вас: добавьте в CI сборку тестов с -fsanitize=undefined,address, включите -Wall -Wextra -Werror хотя бы для нового кода, и заведите правило в clang-tidy на reinterpret_cast между несовместимыми типами. Для критичных к безопасности мест — прогон через Godbolt как часть код-ревью, а не после инцидента. Дальше смотрите в сторону clang static analyzer: он находит часть путей с UB, которые UBSan пропустит из-за непокрытых тестами веток.
Проверка рекламы на риски: Что в макете вызовет вопросы ФАС: превосходные степени, сравнения, обещания результата, маркировка.
Спросить за 40 ₽Источники: GCC Optimize Options — официальная документация опций оптимизации, Clang UndefinedBehaviorSanitizer documentation
Частые вопросы
А если у меня старый компилятор, GCC 9 или Clang 10?
Почему код падает только в релизе, а в отладке всё нормально?
Можно ли просто отключить оптимизации в проблемных местах?
volatile защищает от гонок между потоками?
Как понять, включён ли строгий алиасинг в моём проекте?
UBSan замедляет программу — стоит ли держать его в CI?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.