Files

29 KiB
Raw Permalink Blame History

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: компилятор не обязан давать какой-либо конкретный результат, и на разных платформах или при разных уровнях оптимизации результат может отличаться, включая непредсказуемое значение.

Материалы