Files
eduplan-cpp-eltex/lessons/D2_cpp.md
T

269 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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