Уроки D2-D7 (14 файлов): деревья/куча, epoll, потоки, gdb, графы, L2-L3, ДП, TCP, bash, ядро, docker, мок-интервью + cards_src 613

This commit is contained in:
Kodlo-chan
2026-09-25 19:15:23 +07:00
parent 52d401f5e3
commit d313c28919
17 changed files with 5768 additions and 63 deletions
+268
View File
@@ -0,0 +1,268 @@
# 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