269 lines
29 KiB
Markdown
269 lines
29 KiB
Markdown
# D2, часть 3. C++ по промахам (45 минут, плотно)
|
||
|
||
Пять тем, которые почти гарантированно спросят и почти гарантированно проверят не на
|
||
определении, а на конкретном примере: посчитать размер структуры, найти double-free в коде,
|
||
объяснить, что делает `std::move`, назвать, что именно является UB. Разбор без разгона — сразу
|
||
к механизму.
|
||
|
||
## 1. Выравнивание и padding
|
||
|
||
```c++
|
||
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) копирование** каждого поля.
|
||
|
||
```c++
|
||
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-ссылке. Единственный
|
||
эффект — при выборе перегрузки компилятор теперь предпочитает конструктор/оператор
|
||
присваивания **перемещением**, а не копированием, если такой у типа определён.
|
||
|
||
```c++
|
||
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 в физическую память, поэтому любое обращение к ней гарантированно и предсказуемо падает
|
||
|
||
## Проверь себя
|
||
|
||
<details>
|
||
<summary>1. Почему sizeof(struct { char a; int b; char c; }) равен 12, а не 6 или 9?</summary>
|
||
6 — это сумма размеров полей без паддинга, физически недостижима из-за требования
|
||
выравнивания int на 4 байта: между a (смещение 0) и b нужно 3 байта паддинга, b занимает
|
||
смещения 4–7, c — смещение 8. 9 байт — это конец полезных данных после c, но итоговый размер
|
||
структуры округляется вверх до кратного выравниванию самого строгого поля (int, 4) — 9
|
||
округляется до 12, добавляя ещё 3 байта хвостового паддинга.
|
||
</details>
|
||
|
||
<details>
|
||
<summary>2. Почему копирование объекта с необъявленными спецфункциями и владеющим указателем даёт double-free?</summary>
|
||
Компилятор генерирует конструктор копирования по умолчанию, если ни одну из пяти спецфункций
|
||
не объявили сами. Он делает побитовое копирование полей — указатель копируется как значение
|
||
адреса, оба объекта получают один и тот же адрес. При уничтожении обоих объектов оба
|
||
деструктора вызывают delete на этом адресе — второй вызов на уже освобождённой памяти.
|
||
</details>
|
||
|
||
<details>
|
||
<summary>3. Почему delete через Base* без virtual-деструктора не роняет программу сразу, а просто течёт?</summary>
|
||
Компилятор жёстко привязывает вызов delete к статическому типу указателя (Base*) на этапе
|
||
компиляции, потому что деструктор не virtual — вызывается только ~Base(). Память под объект
|
||
освобождается корректно (адрес правильный), падения не происходит, но ~Derived() не
|
||
выполняется, и ресурсы, которыми управлял именно Derived (например, отдельный new[]), никогда
|
||
не освобождаются — это утечка, а не крах.
|
||
</details>
|
||
|
||
<details>
|
||
<summary>4. Что конкретно делает std::move и кто выполняет реальное перемещение?</summary>
|
||
std::move — это static_cast к rvalue-ссылке, никакого действия во время выполнения не
|
||
происходит. Реальную работу — например, перенос трёх внутренних указателей vector в новый
|
||
объект и обнуление их у источника — делает move-конструктор/move-оператор присваивания
|
||
конкретного типа, который компилятор выбирает благодаря этому касту.
|
||
</details>
|
||
|
||
<details>
|
||
<summary>5. Почему сдвиг 1 << 32 для 32-битного int — это UB, а не просто 0 или неожиданный результат?</summary>
|
||
Стандарт определяет поведение сдвига только для сдвига на число бит меньше разрядности типа.
|
||
Сдвиг на количество бит, равное или большее разрядности (32 для 32-битного int), — UB:
|
||
компилятор не обязан давать какой-либо конкретный результат, и на разных платформах или при
|
||
разных уровнях оптимизации результат может отличаться, включая непредсказуемое значение.
|
||
</details>
|
||
|
||
## Материалы
|
||
|
||
- 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
|