Занятие 18. Тёмная сторона: reinterpret_cast, union, UB
Семнадцать занятий мы строили дисциплину: типы, const, RAII, инварианты. Эта лекция — экскурсия за стену, которую та дисциплина возводит: что происходит, когда программа обращается с памятью «как с байтами», и почему компилятор вправе удалить ваш код целиком. Правила курса не отменяются — в домашних работах этим инструментам делать нечего; но их нужно узнавать в чужом коде (драйверы, протоколы, движки) и понимать контракт с компилятором. Половина сегодняшнего кода — специально сломанная. Все выводы и ассемблер настоящие (Apple M-серия, clang, -O2 там, где не сказано иное).
Биты под типами
Четыре каста — четыре степени риска
| каст | что делает | риск |
|---|---|---|
static_cast<double>(i) |
честное преобразование значения: биты меняются по правилам | проверен компилятором |
const_cast<T&>(x) |
снимает const (занятие 11) | менять настоящую константу — UB |
dynamic_cast<D*>(b) |
спуск по иерархии с проверкой в рантайме (vtable, занятие 7) | nullptr при неудаче |
reinterpret_cast<T*>(p) |
переинтерпретация: биты не трогает, меняет только «очки» | почти всегда на грани UB |
Сравните: static_cast<double>(1) строит новые биты 0x3FF0…, а reinterpret_cast оставил бы битовый узор единицы-int и назвал его double — вышло бы 4.94·10⁻³²⁴. C-каст (T)x перебирает эти касты и молча берёт первый подошедший — поэтому в курсе пишем именованные: намерение видно и ревью, и компилятору.
Легальный мост: std::bit_cast
Занятие 3 рисовало IEEE 754 на доске — теперь видны живые биты: знак · 8 бит экспоненты · 23 бита мантиссы. Удвоение числа — это +1 к экспоненте; минус — один старший бит; хвост 0.1f — та самая бесконечная двоичная дробь, обрезанная до …CCCD. bit_cast (C++20) — единственный прямой легальный способ переложить биты между типами (до него — std::memcpy). А классика интернета *(uint32_t*)&f — UB, и ниже станет ясно почему.
0x5f3759df: легенда 1999 года
Идея: биты float — почти масштабированный логарифм числа (экспонента и есть log₂). Сдвиг вправо делит логарифм на два, вычитание из подобранной константы даёт минус — получается грубое $x^{-1/2}$, а шаг Ньютона дошлифовывает. Замеры:
Точности 0.175% хватало освещению в игре 1999 года. Сегодня аппаратный корень быстрее: легенда устарела — а мост «биты ↔ число» вечен. (В оригинале вместо bit_cast — каст указателей и комментарий, который мы не процитируем.)
union: память по совместительству
Все члены union делят один адрес; sizeof равен максимуму из членов; «активен» тот, куда писали последним. Честные применения: варианты «или-или» в протоколах и форматах, экономия памяти в узлах структур данных.
Но есть два подвоха. Первый: читать не тот член (type punning) легально в C и формально UB в C++ — компиляторы поддерживают это как расширение, однако контракт нарушен, и мост всё тот же: bit_cast. Второй: union не помнит, кто активен, — это ваша ручная бухгалтерия с тегом рядом, и она регулярно ломается.
Наследник
Типобезопасный union — std::variant: хранит тег сам и не даст прочитать не тот член. Встретим через занятие.
UB и оптимизатор
Неопределённое поведение — это контракт
Стандарт делит мир на определённое поведение (гарантии) и UB: «стандарт не накладывает никаких требований». Никаких — значит не «упадёт», а «что угодно». Компилятор оптимизирует, предполагая, что UB не случается: это сделка — вы обещаете играть по правилам, он генерирует быстрый код без страховочных проверок. Поэтому «у меня же работает» ничего не доказывает: с другим флагом, компилятором или в другой день результат другой.
Знакомые UB, вдоль которых мы шли весь семестр: переполнение signed int (занятия 3, 12), выход за границы массива (3, 16), висячий указатель и double free (11, 16), delete вместо delete[] (11), гонка данных (16), компаратор с «≤» (17).
Компилятор вам верит. Буквально
wraps: переполнение signed — UB, значит его «не бывает», значит работает алгебра идеальных чисел: $x + 1 > x$ истинно всегда — одна инструкция. deref: раз *p выполнилось, p не мог быть null — проверка мертва, компилятор её убрал. Ровно такой паттерн (разыменование до проверки) стал знаменитой уязвимостью в ядре Linux в 2009 году: удалённый оптимизатором if открыл дорогу эксплойту. Мораль: UB отменяет не строку — он отменяет рассуждения о соседнем коде.
Одна программа — два ответа
Нарушено правило strict aliasing: к памяти можно обращаться только через её настоящий тип (исключение — char*/std::byte*, законные «обходчики байтов»). -O0 честно перечитал память и напечатал 0; -O2 применил разрешённую сделкой оптимизацию и вернул 1. Оба ответа «правильны» — некорректна программа. Вот почему *(uint32_t*)&f — мина замедленного действия, а легальные мосты (bit_cast, memcpy) компилятор превращает в те же быстрые инструкции без всякого UB.
Как жить: санитайзер и на это есть
UBSan (-fsanitize=undefined) вставляет проверки в местах потенциального UB и печатает точную строку с объяснением. Инструментальная триада курса собрана: ASan — память (занятия 7, 11, 16), TSan — гонки (16), UBSan — арифметика и касты. Цена — замедление в разы, поэтому это отладочные флаги, не боевые.
Диагностический симптом на всю жизнь: программа меняет поведение между -O0 и -O2 — почти всегда это UB, а не «глюк компилятора». Компилятор почти всегда прав.
Выравнивание
У каждого типа есть любимые адреса
Выравнивание типа — адреса, кратные его alignof: int живёт по адресам, делящимся на 4, double — на 8. Компилятор гарантирует это для переменных, полей и результата new. Зачем: шина читает память выровненными словами — невыровненный доступ превращается в два чтения со склейкой, на части архитектур это аппаратная ошибка, а для атомарных операций (занятие 16) выравнивание — условие корректности.
24 байта против 16: цена порядка полей
double требует адрес ÷ 8 — после одинокого char компилятор вставляет 7 байт дыры; хвост добивается до кратности 8, чтобы массив таких структур сохранял выравнивание каждого элемента. Отсюда правило укладки:
Поля — от больших к малым
Дыры схлопываются: 24 → 16, минус треть памяти всего массива структур. Проверяется за минуту печатью sizeof и offsetof.
#pragma pack(1) убирает дыры принудительно — легально и нужно для бинарных форматов файлов и сети, но поля теряют выравнивание: читать их напрямую нельзя, только memcpy в выровненную переменную (который компилятор сведёт к оптимальным инструкциям).
Проверьте себя
-
Прочитайте биты. Почему
bit_cast<uint32_t>(2.0f) == 0x40000000? Что даст 4.0f? 0.5f? -
Найдите UB в трёх фрагментах:
int m = (l + r) / 2;· записьp.f = 1;и чтениеp.iиз union ·int* q = new int[4]; delete q; -
Уложите поля:
struct S { char tag; long id; short port; char flag; };— sizeof? как сжать?
Домашнее задание (сдача через Git)
- Инспектор битов:
dumpFloat(float)печатает знак, экспоненту и мантиссу отдельными полями (bit_cast + маски); проверьте на 1.0f, 0.1f, 1e38f и на 0.1f + 0.2f — занятие 3 передаёт привет. - Суд над хаком: реализуйте q_rsqrt, измерьте по методике занятия 16 точность и скорость против
1/sqrtfна своей машине; вердикт с числами. - Охота на UB: в выданном файле шесть фрагментов — найдите UB в каждом, назовите термином, почините; сверьтесь запуском под UBSan.
- Диета для структуры: дана структура на 48 байт — переставьте поля до минимума; приложите offsetof-карту до и после.
- * «Глюк компилятора»: соберите собственный пример (не из лекции), где -O0 и -O2 дают разные ответы; объясните, какое обещание нарушено.
Следующее занятие — квадратичные сортировки: выбором и вставками. Первые честные сортировки курса: инварианты, число сравнений и обменов — и почему «квадратичная» не значит «бесполезная».