
Ссылки и указатели в C++: от основ к безопасности и современному коду
}
// … остальной код
return 0;
}
НА ЗАМЕТКУ
Инструменты такие как Valgrind и Windows Performance Monitor позволяют визуализировать использование виртуальной памяти.
ПРИМЕЧАНИЕ
Начиная с C++17, библиотека std::pmr (Polymorphic Memory Resources) предоставляет гибкий механизм для настройки аллокаторов, позволяя управлять выделением памяти – в том числе с учётом особенностей виртуальной памяти – через полиморфные ресурсы без привязки к конкретным типам.
ВАЖНО
Тестируйте на системах с малым объёмом оперативной памяти, чтобы смоделировать использование свопа и возникновение ситуаций исчерпания памяти (OOM); для установки ограничений в Linux рекомендуется использовать утилиту ulimit.
Жизненный цикл переменных от рождения до смерти
Каждый объект имеет lifetime: от инициализации до разрушения. Локальные: от объявления до конца блока. Статические: от старта программы. Динамические: от new до delete. Временные (temporaries): до конца выражения. Понимание ключ к избежанию UB.
НА ЗАМЕТКУ
В C++17 структурированные привязки (structured bindings) и лямбда-выражения могут влиять на время жизни (lifetime) объектов, поскольку при захвате переменных лямбдой или их распаковке через structured bindings важно учитывать, сохраняются ли ссылки на временные объекты или локальные переменные, чей срок жизни может завершиться раньше, чем использование этих ссылок, что потенциально приводит к неопределённому поведению.
ПРИМЕЧАНИЕ
Dangling reference («висячая» ссылка) возникает, когда объект уже уничтожен, а ссылка или указатель на него по-прежнему существует и может быть случайно использован, что приводит к неопределённому поведению.
ВАЖНО
Рекомендуется использовать спецификатор constinit для статических констант, чтобы гарантировать их инициализацию на этапе компиляции и избежать проблем, связанных с порядком инициализации статических переменных.
Продление жизни неправильно
Выражение const int& ref = 5 + 3; в C++ продлевает время жизни временного объекта, созданного результатом 5 + 3, на всё время существования ссылки ref, поэтому использование ref в пределах её области видимости корректно и не приводит к неопределённому поведению; неопределённое поведение возникло бы только при попытке обращения к временному объекту после уничтожения ссылки или если бы ссылка была неконстантной.
Правильные scopes и RAII
Следует объявлять переменные в максимально узкой области видимости, а в C++26 – использовать атрибут std::lifetimebound для получения предупреждений компилятора о потенциальных проблемах с временем жизни объектов.
Выравнивание и alignof, sizeof vs реальный размер
Давайте нырнём глубже в одну из самых "технических" тем памяти в C++ выравнивание (alignment). Представьте процессор как привередливого читателя: он предпочитает брать данные "пачками" по определённым границам, как книги с полки, выровненные по краю. Если данные не выровнены, чтение замедляется или даже приводит к ошибкам. Выравнивание это правило размещения данных в памяти так, чтобы их адрес был кратен определённому числу (обычно степени 2, как 4, 8, 16 байт). Это оптимизирует доступ CPU, минимизирует циклы кэша и предотвращает hardware-исключения на некоторых архитектурах (например, ARM или старые x86).
Почему выравнивание нужно? Процессоры (x86, ARM, RISC-V) спроектированы для атомарного чтения/записи по словам (word size: 4 байта на 32-bit, 8 на 64-bit). Невыровненный доступ может требовать двух операций вместо одной, замедляя код в 2–10 раз или вызывая bus error/SIGBUS. В C++ стандарт гарантирует естественное выравнивание для встроенных типов: char 1 байт, int обычно 4, double 8. Для пользовательских типов (struct/class) выравнивание максимум из выравниваний полей.
alignof: это оператор (с C++11), возвращающий минимальное требуемое выравнивание для типа в байтах. Например, alignof(int) = 4 на большинстве платформ. Он constexpr, так что полезен в шаблонах и compile-time проверках.
sizeof vs реальный размер: sizeof(T) размер типа в байтах, включая padding (добавочные байты для выравнивания). Padding вставляется компилятором между полями структур, чтобы каждое поле начиналось на своём выравнивании. Общий sizeof структуры кратен её выравниванию. "Реальный" размер сумма размеров полей без padding, но память тратится на padding для эффективности.
Рассмотрим аналогию: структура как вагон поезда. Поля пассажиры разного роста (размера). Выравнивание правило: высокий (int) садится только у окна (кратно 4). Если после короткого (char) нет места добавляем пустые сиденья (padding).
Пример базовый:
#include
struct NonAligned {
char c; // 1 байт, alignof=1
int i; // 4 байта, alignof=4 → padding 3 байта после c
};
int main() {
std::cout << "sizeof(NonAligned): " << sizeof(NonAligned) << " (ожидаемо 8)" << std::endl;
std::cout << "alignof(NonAligned): " << alignof(NonAligned) << " (4, как max полей)" << std::endl;
return 0;
}
Здесь sizeof=8 (1+3 padding+4), хотя "реальный" 5 байт. Padding "раздувает" структуру.
ДОПОЛНЕНИЕ
Выравнивание оказывает значительное влияние на размещение массивов: первый элемент всегда выровнен в соответствии с требованиями его типа, а последующие располагаются с шагом, равным sizeof(тип). В виртуальной памяти страницы выровнены по границе 4 КБ (4096 байт), что напрямую связано с работой аллокаторов. Кроме того, для эффективного использования SIMD-инструкций (например, SSE, AVX) необходимо выравнивание данных по 16-, 32- или 64-байтным границам – его отсутствие может привести либо к неопределённому поведению, либо к существенному замедлению выполнения.
НА ЗАМЕТКУ
alignof(max_align_t) определяет максимальное выравнивание, поддерживаемое платформой (обычно 16 байт на x86-64), и начиная с C++17 типы с выравниванием, превышающим это значение (over-aligned types), объявленные с помощью alignas, требуют использования специализированных аллокаторов, так как стандартные средства выделения памяти могут не обеспечивать необходимое выравнивание.
ПРИМЕЧАНИЕ
Выравнивание данных зависит от платформы: на x86 не выровненный доступ допустим, хотя и замедляет выполнение, тогда как на ARM он приводит к аварийному завершению программы; для выявления таких проблем рекомендуется использовать санитайзер выравнивания —fsanitize=alignment.
ВАЖНО
Доступ к не выровненным данным является неопределённым поведением (undefined behavior) согласно стандарту, даже если на вашей конкретной машине такой код, похоже, работает корректно.
ВОДА
Выравнивание данных подобно правилам парковки: как автомобили ставят на парковке с зазорами (padding), чтобы обеспечить быстрый и безопасный въезд и выезд, так и данные размещают с выравнивающими байтами, чтобы процессор мог эффективно их читать и записывать; без такого выравнивания возникают «пробки» в виде замедления работы или даже «аварии» – неопределённое поведение (undefined behavior).
Ещё пример с несколькими полями:
struct Complex {
char c1; // offset 0, size 1
double d; // alignof=8 → padding 7 байт, offset 8, size 8
char c2; // offset 16, size 1
int i; // alignof=4 → padding 3 байт, offset 20, size 4
// Общий align=8 → padding 4 байт в конце? Нет, sizeof=24 (кратно 8)
};
std::cout << "sizeof(Complex): " << sizeof(Complex) << " (24)" << std::endl;
"Реальный" размер: 1+8+1+4=14, но с padding 24. Оптимизируйте порядок: большие поля сначала.
Игнорирование выравнивания, padding и alignof
Частая ошибка проектировать структуры без учёта порядка полей: смешивать маленькие и большие типы, приводя к огромному padding. Например, чередование char и double "раздувает" структуру в 2 раза, тратя память в массивах или кэше. Ещё: ручное кастирование указателей без проверки alignment int* p = (int*)char_ptr; если char_ptr не кратен 4 UB, краш на non-x86. Игнорирование alignof в шаблонах: аллокаторы без over-alignment в C++11-14 приводят к misalignment. Или использование packed структур (#pragma pack(1)) везде экономит память, но замедляет доступ и не портативно.
НА ЗАМЕТКУ
В сетевых протоках, таких как TCP/IP, упакованные структуры данных часто необходимы для корректного представления байтовых последовательностей, однако прямой доступ к их полям может привести к неопределённому поведению из-за нарушения выравнивания; поэтому для безопасного чтения или записи следует использовать memcpy, который гарантирует корректную обработку данных без риска неопределённого поведения.
ПРИМЕЧАНИЕ
Раньше, в отсутствие alignof, разработчики ориентировались на sizeof для определения выравнивания, однако этот подход не гарантирует точности, поскольку sizeof возвращает размер объекта, а не его требования к выравниванию; с появлением alignof появилась возможность напрямую и точно получать информацию о выравнивании типов.
ВАЖНО
В многопоточной среде нарушение выравнивания (misalignment) может нарушить корректность атомарных операций, которые требуют естественного (natural) выравнивания данных, поскольку аппаратные гарантии атомарности часто действуют только при соблюдении определённых границ выравнивания.
ВОДА
Игнорировать выравнивание при укладке чемодана – всё равно что набивать его хаотично: вещи, возможно, влезут, но открывать и закрывать чемодан станет мучительно, да и пространство окажется использовано неэффективно.
Оптимизация с alignof, alignas и умным дизайном
Используйте alignof для проверок: в static_assert или runtime. Оптимизируйте структуры: размещайте поля по убыванию размера/выравнивания минимизирует padding. Для принудительного выравнивания alignas(N) (C++11+): alignas(16) int x; x кратен 16 (для SIMD). Для структур: alignas на поля или всю struct.
Пример оптимизации:
#include
struct NonOptimized {
char c; // 1 + 3 pad
int i; // 4
// sizeof=8
};
struct Optimized {
int i; // 4
char c; // 1 + 3 pad (но в конце, не влияет на следующие)
// sizeof=8, но в массиве/кэше эффективнее
};
struct OverAligned {
alignas(32) double data[4]; // Для AVX-512
};
int main() {
static_assert(alignof(Optimized) == 4, "Проверка выравнивания");
std::cout << "sizeof(NonOptimized): " << sizeof(NonOptimized) << std::endl;
std::cout << "sizeof(Optimized): " << sizeof(Optimized) << std::endl;
std::cout << "alignof(OverAligned): " << alignof(OverAligned) << std::endl; // 32
return 0;
}
Для динамической памяти: std::aligned_alloc (C++17) или custom allocators. В шаблонах: std::aligned_storage для буферов.
НА ЗАМЕТКУ
В C++23 оператор alignof получил расширенную поддержку и теперь может применяться к неполным типам в определённых контекстах, где выравнивание может быть определено без полного определения типа.
ПРИМЕЧАНИЕ
Инструменты вроде pahole (доступные в Linux) позволяют визуализировать выравнивание и заполняющие байты (padding) в структурах данных, что помогает анализировать их макет в памяти и оптимизировать потребление памяти.
ВАЖНО
Всегда проверяйте смещение полей структуры с помощью offsetof(Struct, field), так как оно показывает реальное смещение с учётом выравнивания и padding, добавленного компилятором для оптимизации доступа к данным.
ДОПОЛНЕНИЕ
Padding можно уменьшить с помощью битовых полей, однако они подчиняются собственным правилам выравнивания.
sizeof vs реальный размер почему компилятор "обманывает" нас байтами
Теперь давайте разберёмся с одной из самых коварных ловушек в C++: почему sizeof иногда возвращает число, которое кажется "завышенным", и что такое "реальный" размер данных. Представьте, что вы упаковываете чемодан для поездки: "реальный" размер объём ваших вещей (рубашки, брюки), но компилятор добавляет "пустоты" (padding), чтобы чемодан был удобным в транспортировке выровненным и быстрым в доступе. В итоге sizeof это размер всего чемодана, а не только вещей внутри. Это не баг, а фича для производительности, но без понимания приведёт к трате памяти и сюрпризам.
Что такое sizeof? Оператор sizeof(T) (или sizeof expr) возвращает размер типа T или выражения в байтах это compile-time константа. Для простых типов: sizeof(char)=1, sizeof(int)=4 (обычно), sizeof(double)=8. Для массивов: sizeof(arr) = N * sizeof(T). Но для структур/классов sizeof включает padding байты, вставленные компилятором для выравнивания полей. "Реальный" размер сумма sizeof каждого поля без padding, но в памяти объект занимает sizeof, включая "пустоты".
Почему разница? Из-за выравнивания (alignment): процессор быстрее работает с данными на границах (кратно 4/8 байтам). Компилятор добавляет padding, чтобы каждое поле начиналось на нужном адресе, и весь объект был кратен своему выравниванию (для массивов). Без padding доступ замедлился бы или вызвал UB.
Аналогия: представьте полки в шкафу. Книги (поля) разной толщины: тонкая (char=1B) и толстая (int=4B). Чтобы толстая стояла ровно (выровнена), после тонкой добавляем "пустые обложки" (padding). Итоговый "шкаф" (sizeof) больше суммы толщин книг.
Пример классический:
#include
struct Example {
char a; // 1 байт (offset 0)
int b; // 4 байта, но требует выравнивания 4 → 3 байта padding после a (offset 4)
char c; // 1 байт (offset 8)
};
int main() {
std::cout << "sizeof(Example): " << sizeof(Example) << " байт (ожидаемо 12)" << std::endl;
std::cout << "Реальный размер полей: " << sizeof(char) + sizeof(int) + sizeof(char) << " байт (6)" << std::endl;
std::cout << "Offset b: " << offsetof(Example, b) << " (4, с padding)" << std::endl;
std::cout << "Offset c: " << offsetof(Example, c) << " (8)" << std::endl;
return 0;
}
Здесь sizeof=12 (1 + 3 pad + 4 + 1 + 3 pad, чтобы весь struct был кратен 4). "Реальный" 6 байт, но память тратит 12 в 2 раза больше! В массиве Example arr[1000] это 12KB вместо 6KB.
ДОПОЛНЕНИЕ
Оператор sizeof для классов в C++ включает размер всех его членов, а также дополнительные данные, необходимые для реализации полиморфизма, такие как указатель на виртуальную таблицу (vtable), который обычно занимает 8 байт на 64-битных системах; для объединений (union) sizeof возвращает размер наибольшего поля, поскольку все поля разделяют одну и ту же область памяти; битовые поля (bit-fields) позволяют более плотную упаковку данных, но компилятор всё равно может добавлять выравнивающие байты (padding) в зависимости от архитектуры и соглашений о выравнивании; в C++20 для более эффективного управления памятью можно использовать std::bitset или атрибуты упаковки (например, [[gnu::packed]]), однако их эффективность не гарантируется на всех платформах; кроме того, размеры базовых типов, таких как int, зависят от целевой платформы – например, в моделях данных ILP32 и LP64 int может занимать 4 или 8 байт соответственно, что также влияет на итоговый размер структур и классов.
НА ЗАМЕТКУ
Согласно стандарту языка C++, sizeof(void) не определён, и его использование приводит к ошибке компиляции; однако некоторые компиляторы (например, GCC в режиме расширений) могут допускать sizeof(void) == 1 как расширение, но это не соответствует стандарту. Тип void не имеет размера, поскольку не может быть инстанцирован, и утверждение о его «бесконечности» некорректно – это просто неполный тип, не предназначенный для создания объектов. Применение оператора sizeof к функциям действительно является ошибкой компиляции, так как функции не имеют размера в смысле объектов памяти.
ПРИМЕЧАНИЕ
В embedded-системах использование sizeof критично, поскольку избыточный padding напрямую расходует ограниченные ресурсы ROM и RAM, тогда как на десктопных платформах padding может вызывать промахи в кэше, что приводит к снижению производительности.
ВАЖНО
Никогда не предполагайте значение sizeof без явной проверки – всегда используйте static_assert(sizeof(T) == expected), поскольку выравнивание (padding) и, как следствие, размер структуры могут изменяться в зависимости от компилятора, его версии или флагов сборки.
ВОДА
Оператор sizeof подобен ценнику в магазине: он показывает «реальную» стоимость товара, но на деле итоговая цена оказывается выше из-за налогов и упаковки (padding); без понимания этого легко превысить выделенный бюджет памяти.
Ещё пример с классом:
class Base { virtual ~Base() {} }; // vtable ~8 байт
class Derived : public Base { int x; }; // sizeof ~16 (8 vtable + 4 int + 4 pad)
std::cout << "sizeof(Derived): " << sizeof(Derived) << std::endl;
"Реальный" 4 (int), но с vtable и padding 16.
Предположение, что sizeof = сумма полей, и игнорирование "реального" размера
Новички часто рассчитывают память как сумму sizeof полей: malloc(sum_sizes) для структуры, но без padding аллокатор выделит мало, и поля "съедут", вызвав UB или краш. Ещё: сериализация (запись в файл/сеть) без учёта padding данные коррумпированы на другой платформе. Или массивы структур: ожидание плотной упаковки, но padding "раздувает" кэш, замедляя loop'ы. В legacy-коде: #pragma pack(1) везде для "экономии" да, sizeof уменьшится, но доступ замедлится, и портативность сломается (UB на non-x86).
НА ЗАМЕТКУ
В базах данных и на GPU избыточное выравнивание (padding) снижает эффективность использования пропускной способности, поскольку данные не помещаются в фиксированные по размеру пакеты, что приводит к фрагментации и неоптимальной передаче информации.
ПРИМЕЧАНИЕ
Битовые поля могут показаться удобным решением, однако их выравнивание (padding) и порядок следования битов определяются реализацией, поэтому полагаться на них не следует.
ВАЖНО
Игнорирование различий в шаблонах приводит к некорректной работе generic-кода на типах с неожиданным выравниванием (padding), что нарушает его корректность и предсказуемость.
ВОДА
Предполагать размер структуры без учёта выравнивания – всё равно что планировать бюджет, игнорируя налоги: на бумаге выглядит выгодно, но в реальности нехватка памяти ударит по карману.
Проверка sizeof, оптимизация и расчёт "реального" размера
Всегда используйте sizeof для выделения/копирования: malloc(sizeof(T)). Для "реального" суммируйте поля вручную или используйте offsetof для offsets. Оптимизируйте: сортируйте поля по убыванию sizeof минимизирует padding. Для packed: #pragma pack или attribute((packed)) но только когда нужно (сети, файлы), и читайте с memcpy. В modern C++: bit-fields для флагов, std::byte для raw-буферов.
Пример оптимизации и проверки:
#include
#include
struct NonPacked {
char a; int b; char c; // sizeof=12, real=6
};
struct Packed {
int b; char a; char c; // sizeof=8, real=6 (padding 2 в конце)
};
#pragma pack(push, 1) // Tight packing
struct TightPacked {
char a; int b; char c; // sizeof=6, но медленный доступ!
};
#pragma pack(pop)
int main() {
static_assert(sizeof(TightPacked) == 6, "Проверка packed");
std::cout << "sizeof(NonPacked): " << sizeof(NonPacked) << std::endl;
std::cout << "sizeof(Packed): " << sizeof(Packed) << std::endl;
std::cout << "sizeof(TightPacked): " << sizeof(TightPacked) << std::endl;
// Для сериализации: memcpy(buffer, &obj, sizeof(obj)) но с packed!
return 0;
}
НА ЗАМЕТКУ
Инструменты вроде Godbolt позволяют наглядно увидеть ассемблерный код, в котором отражается, как выравнивание (padding) влияет на операции загрузки и сохранения данных.
ПРИМЕЧАНИЕ
В C++20 концепции позволяют проверять свойства типов с помощью выражений вроде requires sizeof(T) <= limit, что обеспечивает компактную и читаемую проверку ограничений на размер типа непосредственно в объявлении концепции.
ВАЖНО
Для кроссплатформенной совместимости не следует полагаться на фиксированные размеры целочисленных типов; вместо этого рекомендуется использовать типы с чётко определённой разрядностью, такие как uint32_t из заголовка .
ДОПОЛНЕНИЕ
Массивы переменной длины (VLA) в стандарте C99 поддерживают вычисление размера во время выполнения с помощью оператора sizeof, однако в C++ они не входят в стандарт и использовать их не рекомендуется – вместо этого следует применять std::vector, который обеспечивает аналогичную гибкость и безопасность при управлении динамическими массивами.
Упражнения: &, sizeof, alignof
Задача 1: Адрес переменной на стеке
Вы должны написать программу, которая объявляет целочисленную переменную и выводит на экран её адрес в памяти. Это демонстрирует, как в языке C++ можно получить доступ к адресу переменной с помощью унарного оператора взятия адреса &, а также как стандартный поток вывода std::cout может отображать адреса в виде шестнадцатеричных значений.
Подсказка: используйте оператор & перед именем переменной, чтобы получить её адрес, и передайте результат напрямую в std::cout. Обратите внимание, что тип адреса – указатель, и поток вывода автоматически форматирует его в читаемом виде (обычно в шестнадцатеричной системе).
Задача 2: Размер простого типа
Вы должны написать программу, которая выводит на экран размер базового целочисленного типа данных в байтах, используемого в текущей системе и компиляторе. Эта информация полезна для понимания разрядности данных и особенно важна при разработке переносимого кода или при работе с низкоуровневыми операциями, где точный размер типов влияет на корректность работы программы.
Подсказка: воспользуйтесь оператором sizeof, применённым к типу int, чтобы получить его размер в байтах. Результат можно напрямую передать в стандартный поток вывода. Учтите, что фактический размер может различаться в зависимости от архитектуры процессора и настроек компилятора, хотя на большинстве современных систем он составляет 4 байта.
Задача 3: Выравнивание базового типа
Вы должны написать программу, которая выводит выравнивание типа double в байтах. Выравнивание определяет, по какому адресу в памяти может быть размещён объект данного типа – процессоры часто требуют, чтобы определённые типы данных начинались с адресов, кратных определённому числу байт, чтобы обеспечить эффективный доступ к ним. Ваша задача – использовать оператор alignof, чтобы определить и вывести требуемое выравнивание для типа double на текущей платформе.
Подсказка: в C++ для получения выравнивания типа в байтах используется встроенный оператор alignof(T), который возвращает значение типа size_t. Просто примените его к типу double и выведите результат с помощью std::cout. Убедитесь, что подключили заголовок для работы с потоками ввода-вывода.
Задача 4: Адрес элемента массива
Вы должны написать программу, которая демонстрирует размещение элементов массива в памяти и показывает, что элементы целочисленного массива располагаются последовательно. Для этого объявите массив из трёх целых чисел и выведите адреса первых двух элементов. Программа должна наглядно подтвердить, что адреса отличаются на размер одного элемента (в байтах), что является следствием непрерывного хранения массива в памяти.