29 KiB
D2, часть 3. C++ по промахам (45 минут, плотно)
Пять тем, которые почти гарантированно спросят и почти гарантированно проверят не на
определении, а на конкретном примере: посчитать размер структуры, найти double-free в коде,
объяснить, что делает std::move, назвать, что именно является UB. Разбор без разгона — сразу
к механизму.
1. Выравнивание и padding
struct S { char a; int b; char c; };
static_assert(sizeof(S) == 12);
static_assert(offsetof(S, b) == 4);
static_assert(offsetof(S, c) == 8);
Поля дают 6 байт (1+4+1), но sizeof(S) == 12 на x86-64. Раскладка по смещениям:
a — смещение 0 (1 байт); дальше 3 байта паддинга, потому что int b требует
выравнивания на 4 байта (адрес поля должен делиться на 4), а следующий свободный адрес — 1;
b занимает смещения 4–7; c — смещение 8 (1 байт). На этом полезные данные кончаются на
9 байте, но размер всей структуры округляется вверх до кратного выравниванию самого
строгого поля внутри (здесь int, выравнивание 4) — отсюда ещё 3 байта хвостового
паддинга, и итоговый размер 12, а не 9.
Выравнивание — требование от процессора: обращение к int по адресу, не кратному 4, на x86
разрешено, но обходится дороже (может потребовать двух обращений к памяти вместо одного); на
архитектурах со строгим выравниванием (некоторые режимы ARM) — это аппаратное исключение.
Компилятор жертвует местом в памяти ради предсказуемой и быстрой работы с каждым полем.
offsetof(S, member) — макрос, дающий точное смещение поля в байтах, посчитанное так же, как
это делает компилятор при раскладке структуры (в примере выше — 4 и 8). #pragma pack(1)
убирает паддинг полностью — sizeof(S) станет 6, но каждое обращение к b и c идёт по
невыровненному адресу: цена — либо замедление на x86, либо падение на платформах со строгим
выравниванием. #pragma pack оправдан там, где формат байт фиксирован извне (сетевой
протокол, бинарный формат файла) и точное совпадение раскладки важнее скорости доступа.
Ловушки
- Сериализовать структуру побайтовым копированием (
memcpyвсей структуры целиком) и передать по сети/записать в файл, предполагая, что размер равен сумме полей → получатель на платформе с другим выравниванием прочитает мусор из паддинг-байтов как часть данных. - Полагаться на порядок полей в памяти как на что-то определяемое исходным кодом → компилятор вправе вставлять паддинг между полями в порядке их объявления, но не обязан оптимизировать порядок сам — реальную раскладку проверяют
sizeof/offsetof, а не читают код на глаз.
Факты для карточек
- base | sizeof(struct { char a; int b; char c; }) на x86-64? — 12 байт
- core | Смещения полей a, b, c в этой структуре? — a=0, b=4 (после 3 байт паддинга), c=8
- core | Почему в конце структуры ещё 3 байта паддинга? — размер всей структуры округляется вверх до кратного выравниванию самого строгого поля (здесь int, выравнивание 4): 9 → 12
- deep | Что делает #pragma pack(1) и какая у него цена? — убирает паддинг (sizeof(S) станет 6), но доступ к невыровненным полям дороже на x86 и может аппаратно упасть на платформах со строгим выравниванием
Почему дальше: раскладка структуры в памяти — то, что копирует компилятор по умолчанию при копировании объекта; когда объект владеет ресурсом через указатель, это копирование становится опасным — отсюда правило 0/3/5.
2. Правило 0/3/5
Если класс сам управляет ресурсом (владеющий сырой указатель, файловый дескриптор, мьютекс) и поэтому определяет деструктор — он почти наверняка должен явно определить и конструктор копирования, и оператор присваивания копированием (правило трёх), а с C++11 — ещё и конструктор перемещения с оператором присваивания перемещением (правило пяти). Если ни одну из пяти функций не объявить, компилятор генерирует все пять сам; сгенерированная версия копирования — побитовое (memberwise) копирование каждого поля.
class Buffer {
int* data;
size_t n;
public:
Buffer(size_t n) : data(new int[n]), n(n) {}
~Buffer() { delete[] data; }
// конструктор копирования и operator= не объявлены —
// компилятор сгенерирует побитовую копию указателя data
};
Buffer a(10);
Buffer b = a; // побитовая копия: b.data == a.data, один и тот же адрес
``` // при выходе из области видимости оба деструктора вызовут delete[] на одном адресе
Для указателя побитовая копия означает, что `b.data` получает **то же значение адреса**, что
и `a.data`, — не копию массива, а второй указатель на один и тот же блок памяти. Когда `a` и
`b` выходят из области видимости, оба деструктора вызывают `delete[]` на одном и том же
адресе — второй вызов освобождает уже освобождённую память. Это классический **double-free**,
UB; ASAN отмечает его явно, с двумя стеками вызовов — одним для первого `delete[]`, вторым
для попытки повторного.
**Rule of zero.** Вместо ручного написания всех пяти функций чаще доверяют владение готовым
RAII-члену (`std::unique_ptr`, `std::vector`, `std::string`) и не объявляют ни одной из пяти
функций вообще — сгенерированные компилятором версии корректно копируют/перемещают саму
обёртку, а обёртка уже сама правильно управляет ресурсом.
**Факты для карточек**
- base | Какие 5 функций входят в правило пяти? — деструктор, конструктор копирования, оператор присваивания копированием, конструктор перемещения, оператор присваивания перемещением
- base | Что делает сгенерированный компилятором конструктор копирования по умолчанию? — побитовое (memberwise) копирование каждого поля
- core | Почему копирование объекта с владеющим сырым указателем даёт double-free? — оба объекта получают одно и то же значение указателя (адрес), оба деструктора вызывают delete на этом адресе — второй раз на уже освобождённой памяти
- core | Что такое rule of zero? — не объявлять ни одну из пяти спецфункций вручную, доверив владение ресурсом готовым RAII-обёрткам (unique_ptr, vector, string) — их сгенерированные копирование/перемещение уже корректны
Почему дальше: правило 0/3/5 регулирует копирование данных объекта, но у полиморфных объектов есть отдельный, ещё более резкий способ потерять часть состояния при удалении — отсутствие virtual-деструктора.
## 3. virtual-деструктор и vtable
Если у класса есть хотя бы одна `virtual`-функция, компилятор добавляет в каждый объект
скрытый указатель на **vtable** — таблицу указателей на реализации виртуальных функций,
специфичную для конкретного класса (на типичной 64-битной платформе этот указатель — 8 байт,
и он увеличивает размер каждого объекта на эту величину). Вызов `obj->f()` через указатель на
базовый класс разрешается в рантайме: сначала читается указатель на vtable из объекта, потом
из таблицы берётся указатель на нужную функцию — и это уже реализация **фактического** типа
объекта, не типа указателя.
```c++
struct Base {
virtual ~Base() = default; // без virtual — источник утечки ниже
virtual void run() {}
};
struct Derived : Base {
int* owned = new int[1000];
~Derived() override { delete[] owned; }
};
Base* p = new Derived();
delete p; // если ~Base() не virtual: вызовется только ~Base(), ~Derived() не вызовется
Если деструктор Base не virtual, вызов delete p привязывается компилятором к типу
указателя (Base*) на этапе компиляции — вызовется только ~Base(), ~Derived() не
вызовется вообще, хотя объект физически был типа Derived. Derived::owned не освобождается
— прямая утечка. Формально это UB; LeakSanitizer покажет утечку со стеком выделения внутри
конструктора Derived. Правило: если класс задуман как базовый для полиморфного использования
(в нём уже есть другие virtual-методы, объекты удаляются через указатель на базовый класс),
его деструктор обязан быть virtual.
Ловушки
- Забыть
virtualу деструктора базового класса, предназначенного для полиморфного использования →deleteчерезBase*не вызывает~Derived()→ утечка ресурсов, которыми владелDerived, видна по LeakSanitizer со стеком выделения в конструктореDerived. - Вызвать
virtual-функцию из конструктора базового класса, ожидая переопределённое вDerivedповедение → на этом этапе vtable объекта ещё указывает на таблицуBase(подобъектDerivedещё не построен), вызовется версияBase— тихий баг без ошибки компиляции.
Факты для карточек
- base | Что добавляет в объект наличие хотя бы одной virtual-функции? — скрытый указатель на vtable, обычно 8 байт на 64-битной платформе
- core | Что произойдёт при delete через Base*, если ~Base() не virtual, а объект на деле Derived? — вызовется только ~Base(), ~Derived() не вызовется вообще — утечка ресурсов Derived, формально UB
- core | Чем это ловится? — LeakSanitizer, со стеком выделения внутри конструктора Derived
- deep | Что вызовет virtual-функция, вызванная из конструктора базового класса? — версию базового класса, а не переопределённую в наследнике: vtable объекта на этом этапе ещё указывает на таблицу Base
Почему дальше: virtual-деструктор освобождает ресурс через уничтожение объекта; альтернативный способ распорядиться ресурсом объекта — не уничтожить его, а перенести владение — это std::move, и здесь часто путают, что именно он делает.
4. std::move — это каст, а не перемещение
std::move(x) не перемещает данные и не выполняет вообще никакого действия во время
выполнения — это static_cast<T&&>(x), явное приведение объекта к rvalue-ссылке. Единственный
эффект — при выборе перегрузки компилятор теперь предпочитает конструктор/оператор
присваивания перемещением, а не копированием, если такой у типа определён.
std::vector<int> a = {1, 2, 3};
std::vector<int> b = std::move(a);
// std::move(a) сам по себе ничего не делает — просто приводит a к vector<int>&&
// реальную работу выполняет move-конструктор vector: он копирует указатель на
// внутренний буфер a в b и обнуляет указатель у a — O(1), без копирования элементов
Реальную работу делает move-конструктор конкретного типа: для std::vector это означает
скопировать три указателя (начало, конец данных, конец ёмкости) в новый объект и обнулить их
у источника — O(1) вместо O(n) поэлементного копирования. std::move — это лишь явная пометка
программиста «мне больше не нужно значение этого именованного объекта (формально lvalue)»,
позволяющая выбрать move-перегрузку там, где без этой пометки компилятор выбрал бы копирующую.
Стандарт гарантирует только, что объект после перемещения находится в валидном, но
неопределённом состоянии — обращение к его старым данным (например, чтение элементов
std::vector, из которого только что сделали std::move) не UB и не ловится ни одним
санитайзером, но является логической ошибкой: конкретное содержимое непредсказуемо.
Факты для карточек
- base | Что физически делает std::move во время выполнения программы? — ничего: это static_cast к rvalue-ссылке (T&&), явный каст, не операция
- core | Что реально выполняет перемещение данных? — move-конструктор/move-оператор присваивания конкретного типа, выбранный благодаря касту std::move
- core | Что происходит с vector при перемещении и за какое время? — копируются 3 внутренних указателя (начало, конец данных, конец ёмкости) в новый объект, у источника они обнуляются — O(1), без копирования элементов
- deep | В каком состоянии находится объект после std::move(obj) по стандарту? — в валидном, но неопределённом состоянии — использование старых данных не UB, но логическая ошибка, не ловится санитайзерами
Почему дальше: логическая ошибка использования объекта после move не UB и не ловится санитайзерами — но есть отдельная категория ошибок, которая формально UB и которую санитайзеры как раз находят.
5. UB: конкретные случаи и что ловят ASAN/UBSAN
UB (undefined behavior) — поведение, для которого стандарт не накладывает вообще никаких требований: компилятор вправе сгенерировать любой код, в том числе тот, что работает по-разному в отладочной и релизной сборке, потому что оптимизатор строит код в предположении, что UB не происходит.
- Знаковое переполнение.
INT_MAX + 1дляint(INT_MAX = 2147483647на типичной 32-битнойint) — UB, не гарантированное переполнение по модулю, в отличие отunsigned, для которого переполнение определено стандартом (арифметика по модулю 2^разрядность). - Некорректный сдвиг. Сдвиг на число бит, большее или равное разрядности типа (
1 << 32для 32-битногоint), либо сдвиг влево, затрагивающий знаковый бит отрицательного числа — UB. - Нарушение выравнивания. Приведение указателя к типу с более строгим выравниванием и
разыменование (например,
char*, не кратный 4, приведённый кint*и разыменованный) — UB, даже если конкретная архитектура физически позволяет такое чтение. - Разыменование null.
*(int*)nullptr— UB; на практике обычно даётSIGSEGV, потому что ОС намеренно оставляет страницу по адресу 0 непримапленной именно для того, чтобы такие обращения падали предсказуемо, а не читали случайные данные.
ASAN (-fsanitize=address) проверяет ошибки работы с памятью: оборачивает выделения
«красными зонами», обращение к которым — сразу ошибка, и ловит выход за границы,
use-after-free, double-free; встроенный LeakSanitizer ловит утечки. UBSAN
(-fsanitize=undefined) вставляет проверки прямо в код в местах, являющихся UB по стандарту —
переполнение знаковых типов, некорректный сдвиг, нарушение выравнивания при разыменовании —
и печатает точную строку исходного кода при срабатывании. Они проверяют разные категории
(ASAN — адреса памяти, UBSAN — отдельные операции языка) и не заменяют друг друга, поэтому их
включают вместе:
g++ -g -fsanitize=address,undefined -fno-omit-frame-pointer file.cpp -o file
Оба замедляют программу и требуют пересборки с флагом -fsanitize=..., поэтому используются
в отладочных/тестовых сборках, а не в проде.
Ловушки
- Полагаться на то, что
intпереполняется предсказуемо «как unsigned» (по модулю) → UB даёт компилятору право отбросить проверку переполнения при оптимизации, если решит, что переполнения «не бывает» → код, работающий в-O0, ломается в-O2. - Включить только ASAN и решить, что этого достаточно против всех UB → ASAN не ловит знаковое переполнение и некорректные сдвиги — это зона UBSAN, нужны оба флага вместе.
Факты для карточек
- base | Что проверяет ASAN, а что — UBSAN? — ASAN: ошибки работы с памятью (границы, use-after-free, double-free, утечки); UBSAN: операции, являющиеся UB по стандарту (переполнение, сдвиг, выравнивание)
- base | Значение INT_MAX для 32-битного int? — 2147483647
- core | Почему UB опасен именно тем, что код может работать в отладочной сборке и падать в релизной? — оптимизатор релизной сборки строит код в предположении, что UB не происходит, и может убрать проверки, которые, по мнению программиста, должны были сработать
- core | Какой флаг компилятора включает сразу оба санитайзера? — -fsanitize=address,undefined
- deep | Почему разыменование nullptr на практике обычно даёт SIGSEGV, а не тихо читает мусор? — ОС намеренно не отображает страницу по адресу 0 в физическую память, поэтому любое обращение к ней гарантированно и предсказуемо падает
Проверь себя
1. Почему sizeof(struct { char a; int b; char c; }) равен 12, а не 6 или 9?
6 — это сумма размеров полей без паддинга, физически недостижима из-за требования выравнивания int на 4 байта: между a (смещение 0) и b нужно 3 байта паддинга, b занимает смещения 4–7, c — смещение 8. 9 байт — это конец полезных данных после c, но итоговый размер структуры округляется вверх до кратного выравниванию самого строгого поля (int, 4) — 9 округляется до 12, добавляя ещё 3 байта хвостового паддинга.2. Почему копирование объекта с необъявленными спецфункциями и владеющим указателем даёт double-free?
Компилятор генерирует конструктор копирования по умолчанию, если ни одну из пяти спецфункций не объявили сами. Он делает побитовое копирование полей — указатель копируется как значение адреса, оба объекта получают один и тот же адрес. При уничтожении обоих объектов оба деструктора вызывают delete на этом адресе — второй вызов на уже освобождённой памяти.3. Почему delete через Base* без virtual-деструктора не роняет программу сразу, а просто течёт?
Компилятор жёстко привязывает вызов delete к статическому типу указателя (Base*) на этапе компиляции, потому что деструктор не virtual — вызывается только ~Base(). Память под объект освобождается корректно (адрес правильный), падения не происходит, но ~Derived() не выполняется, и ресурсы, которыми управлял именно Derived (например, отдельный new[]), никогда не освобождаются — это утечка, а не крах.4. Что конкретно делает std::move и кто выполняет реальное перемещение?
std::move — это static_cast к rvalue-ссылке, никакого действия во время выполнения не происходит. Реальную работу — например, перенос трёх внутренних указателей vector в новый объект и обнуление их у источника — делает move-конструктор/move-оператор присваивания конкретного типа, который компилятор выбирает благодаря этому касту.5. Почему сдвиг 1 << 32 для 32-битного int — это UB, а не просто 0 или неожиданный результат?
Стандарт определяет поведение сдвига только для сдвига на число бит меньше разрядности типа. Сдвиг на количество бит, равное или большее разрядности (32 для 32-битного int), — UB: компилятор не обязан давать какой-либо конкретный результат, и на разных платформах или при разных уровнях оптимизации результат может отличаться, включая непредсказуемое значение.Материалы
- cppreference: правило трёх (rule of three) — https://en.cppreference.com/w/cpp/language/rule_of_three
- cppreference: конструктор перемещения — https://en.cppreference.com/w/cpp/language/move_constructor
- cppreference: undefined behavior — https://en.cppreference.com/w/cpp/language/ub
- GCC: флаги инструментации (ASAN/UBSAN) — https://gcc.gnu.org/onlinedocs/gcc/Instrumentation-Options.html
- Clang: документация AddressSanitizer — https://clang.llvm.org/docs/AddressSanitizer.html