Алексей Примак
Ссылки и указатели в C++: от основ к безопасности и современному коду

Глава 1

Добро пожаловать в первую главу нашей книги! Мы начинаем с основ понимания, как работает память в C++. Представьте, что ваш компьютер – это огромный город, а память его районы: стек как быстрый, но тесный центр города с автоматической уборкой, куча как просторные пригороды, где вы сами строите и разбираете дома, статика как вечные памятники, стоящие с основания города, а сегмент кода как неизменные законы, по которым всё работает. Мы разберём, где "живут" переменные, почему виртуальная память даёт нам иллюзию бесконечного пространства, как выравнивание делает всё эффективнее, почему sizeof не всегда показывает "реальный" размер, жизненный цикл данных, динамическое выделение, основы RAII и многое другое. Всё это с примерами, аналогиями и практическими упражнениями. Давайте нырнём в мир байтов и адресов это будет увлекательно, как разгадка детектива в лабиринте мегаполиса!

Где что живёт стек, куча, статика и другие сегменты памяти

В C++ память делится на несколько сегментов, каждый из которых предназначен для хранения данных с разными сроками жизни, правилами доступа и механизмами управления. Это как комнаты в огромном доме: кухня для быстрых дел (стек), подвал для долгосрочного хранения (куча), фундамент для постоянных элементов (статика), библиотека для неизменных книг (константная память) и чертежи дома (сегмент кода). Понимание этих сегментов ключ к избежанию ошибок, как крахи или утечки.

Стек (Stack): это область для локальных переменных функций, параметров и информации о вызовах (стек вызовов). Работает по принципу LIFO (Last In, First Out) как стопка тарелок: последняя положенная снимается первой. Размер стека фиксирован и ограничен (обычно 1–8 МБ на поток), выделяется автоматически при входе в функцию и освобождается при выходе. Это делает стек сверхбыстрым, но не подходящим для больших или динамических данных рискуете переполнением (stack overflow). Локальные переменные "живут" только внутри функции.

Куча (Heap): здесь память выделяется динамически во время выполнения программы с помощью операторов вроде new или функций вроде malloc. Куча огромна (ограничена только доступной RAM и виртуальной памятью ОС), но управление ею лежит на программисте: вы сами выделяете и освобождаете память (delete или free). Это идеально для объектов, размер которых неизвестен заранее, как растущие массивы, деревья или списки. Однако забыв освободить и привет, утечка памяти, когда программа "съедает" всё больше RAM.

Статическая память (Static/Global): это сегмент для глобальных переменных, статических локальных переменных (объявленных с static) и констант. Они "живут" весь срок жизни программы: выделяются при запуске (в сегментах data или bss) и освобождаются автоматически при завершении. Нет нужды в ручном управлении, но они занимают место постоянно, что может быть проблемой в embedded-системах. Глобальные переменные видны везде, статические только в своей области видимости.

Сегмент кода (Text/Code Segment): здесь хранится сам исполняемый код программы инструкции процессора. Это read-only область: вы не можете модифицировать код во время выполнения (за исключением редких случаев, как JIT-компиляция). Константы строк вроде "Hello" часто живут здесь или в read-only data.

Константная память (Read-Only Data): Подмножество статики для неизменяемых данных, как строковые литералы или const глобальные. ОС защищает её от записи, чтобы предотвратить ошибки.

НА ЗАМЕТКУ

В многопоточных программах каждый поток обладает собственным стеком (для хранения локальных данных), в то время как куча, статические данные и код разделяются между всеми потоками. Это требует синхронизации – например, с помощью мьютексов или атомарных операций, – чтобы предотвратить гонки данных, возникающие при одновременном доступе нескольких потоков к общим ресурсам.

ПРИМЕЧАНИЕ

В embedded-системах (микроконтроллеры, IoT) объём стека часто ограничивается несколькими килобайтами, а куча может отсутствовать вовсе – память выделяется статически или на стеке. В десктопных и серверных системах куча, напротив, может достигать гигабайтов.

ВАЖНО

Никогда не возвращайте из функции указатель на локальную переменную, размещённую в стеке: после выхода из функции она уничтожается, оставляя висячий указатель (dangling pointer). Его использование вызывает неопределённое поведение – от аварийного завершения программы до незаметной порчи данных.

ДОПОЛНЕНИЕ

Адреса в памяти – это уникальные числовые идентификаторы байтов, начиная с 0. Оператор & (address-of) возвращает адрес переменной. Например: (int x = 5; int* ptr = &x;) Здесь ptr хранит адрес переменной x, которая размещена в стеке и, вероятно, находится рядом с другими локальными переменными. Адреса в стеке обычно имеют «высокие» значения и растут вниз (от больших адресов к меньшим), тогда как адреса в куче – «низкие» и растут вверх (от меньших к большим).

ВОДА

Память устроена как квартира: стек – это временный стол для еды, который автоматически убирается после использования; куча – это шкаф, куда вы сами складываете вещи, рискуя завалить его и потерять контроль; а статика – это мебель, которая всегда остаётся на своём месте. Без должного порядка в такой системе быстро воцаряется хаос.

Игнорирование сегментов памяти и ручное управление без осторожности

Новички часто игнорируют различия: выделяют огромные массивы на стеке (int arr[10000000];), вызывая stack overflow как впихивать мебель в лифт, пока он не сломается. Или возвращают адрес локальной переменной: int* func() { int x=5; return &x;} UB, данные "исчезнут". На куче: new без delete утечки, программа "раздувается" со временем. Глобальные переменные везде "спагетти-код", трудно отлаживать. Ещё: смешивание malloc/free с new/delete, что ломает конструкторы/деструкторы.

НА ЗАМЕТКУ

В устаревшем legacy-коде ручное управление памятью в куче без применения идиомы RAII считалось нормой, однако, согласно статистике Microsoft, именно этот подход становится причиной около 70 % всех ошибок.

ПРИМЕЧАНИЕ

Stack Overflow – это не только популярный сайт для вопросов и ответов по программированию, но и реальная ошибка времени выполнения, возникающая из-за переполнения стека вызовов, что обычно вызвано слишком глубокой рекурсией или чрезмерным объёмом локальных данных в функциях.

ВАЖНО

В многопоточной среде без синхронизации возникают гонки данных (data races): когда один поток записывает значение, а другой одновременно читает его, результат становится непредсказуемым.

Осознанное размещение, RAII и современные инструменты

Выбирайте сегмент по нуждам: локальное стек, динамическое куча с умными указателями, постоянное статика. Введите RAII (Resource Acquisition Is Initialization): ресурсы (память) приобретаются в конструкторе, освобождаются в деструкторе. Это основа современных C++: std::unique_ptr для уникального владения (авто-delete), std::shared_ptr для совместного (ref-counting).

Пример:

#include <iostream>

#include <memory>

#include <vector>

int globalVar = 42; // Статика

void stackExample() {

int x = 5; // Стек

std::cout << "Адрес на стеке: " << &x << std::endl;

}

std::unique_ptr<int> heapExample() {

return std::make_unique<int>(10); // Куча, RAII: авто-освобождение

}

int main() {

stackExample();

auto ptr = heapExample(); // Безопасно, ptr владеет памятью

std::vector<int> dynamicArray(1000000); // Куча внутри vector

std::cout << "Глобальный адрес: " << &globalVar << std::endl;

return 0;

}

Для глобальных: минимизируйте, используйте singleton если нужно.

НА ЗАМЕТКУ

RAII (Resource Acquisition Is Initialization) применяется не только для управления памятью, но и для безопасной и автоматической очистки других ресурсов, таких как файлы, сетевые сокеты и мьютексы, гарантируя их корректное освобождение при выходе из области видимости.

ПРИМЕЧАНИЕ

В C++11 и более поздних стандартах предпочтительно использовать std::make_unique и std::make_shared вместо прямого применения оператора new, поскольку эти вспомогательные функции обеспечивают безопасное и идиоматичное управление памятью, автоматически предотвращая утечки и исключения при конструировании объектов, а также улучшая читаемость и сокращая дублирование типов.

ВАЖНО

Для выявления утечек памяти и ошибок обращения к памяти рекомендуется использовать инструменты вроде Valgrind или AddressSanitizer (ASan), которые позволяют обнаруживать подобные проблемы на этапе тестирования программы.

Статическая память вечные жители программы

Статическая память – это "фундамент" вашей программы, где живут данные, существующие от запуска до завершения. Представьте её как незыблемые стены дома: они строятся при постройке и рушатся только при сносе. Сюда относятся глобальные переменные (видны везде), статические локальные (видны только в функции, но живут вечно) и статические члены классов. Выделение происходит при загрузке программы (в сегментах .data для инициализированных и. bss для нулевых), освобождение автоматически ОС при exit.

Почему статическая? Потому что lifetime вся программа. Нет динамики, как в куче, но и нет автоматической "уборки" стека. Идеально для констант, кэшей или shared состояний. Но осторожно: глобальные источник "спагетти", статические локальные источник скрытого state, влияющего на реентерабельность.

Пример:

#include <iostream>

int global = 42; // Глобальная, статика

void func() {

static int localStatic = 10; // Статическая локальная, инициализируется раз

std::cout << localStatic++ << std::endl;

}

int main() {

func(); // 10

func(); // 11

return 0;

}

НА ЗАМЕТКУ

В секции .bss нулевые глобальные переменные не занимают места в исполняемом файле, поскольку операционная система автоматически обнуляет эту область памяти при загрузке программы.

ПРИМЕЧАНИ

Глобальные объекты инициализируются до входа в функцию main, однако порядок их инициализации между разными единицами трансляции (translation units) не определён, что может привести к так называемому «статическому порядковому фиаско» (static initialization order fiasco), когда один глобальный объект зависит от другого, ещё не инициализированного.

ВАЖНО

Следует избегать изменяемых глобальных переменных; вместо них рекомендуется использовать константы или thread_local (начиная с C++11) для обеспечения потокобезопасности.

ДОПОЛНЕНИЕ

Статическая константа, объявленная как constexpr int c = 5;, вычисляется на этапе компиляции и может безопасно размещаться в заголовочном файле без нарушения правила одного определения (ODR), поскольку constexpr переменные неявно обладают внутренней линковкой в контексте заголовков, если не объявлены как extern.

ВОДА

Статика подобна семейным реликвиям: она всегда под рукой, но её повреждение может нанести ущерб всей семье.

Злоупотребление статикой и глобальными

Глобальные везде: int counter; в header multiple definitions. Или mutable глобальные в многопотоке без sync races. Статические локальные в функциях: скрытый state, функции не pure, трудно тестировать. Инициализация сложных глобальных (с функциями) deadlock или UB при order fiasco.

НА ЗАМЕТКУ

В крупных кодовых базах использование глобальных переменных считается антипаттерном согласно принципам GoF, так как оно нарушает инкапсуляцию, затрудняет тестирование и поддержку кода, а также может приводить к непредсказуемому поведению из-за неявных зависимостей.

ПРИМЕЧАНИЕ

Статические члены класса разделяются между всеми объектами этого класса и требуют отдельного определения вне тела класса.

ВАЖНО

Статические объекты действительно живут в течение всего времени выполнения программы, поэтому возврат указателя на них формально допустим, однако это несёт риски, связанные с их неожиданной модификацией из разных частей кода, что может привести к ошибкам и нарушению инкапсуляции.

Минимизация статики, const и alternatives

Минимизируйте глобальные: используйте namespaces или singleton'ы с lazy init. Для констант constexpr. Статические локальные заменяйте на параметры функций. В многопотоке: thread_local int tl;.

Пример singleton:

class Singleton {

public:

static Singleton& get() {

static Singleton instance; // Безопасно в C++11 (magic statics)

return instance;

}

private:

Singleton() {}

};

НА ЗАМЕТКУ

Ключевое слово constinit (введённое в C++20) гарантирует, что переменная инициализируется статически – то есть на этапе компиляции или до запуска программы – и предотвращает динамическую инициализацию во время выполнения, что особенно полезно для статических констант, когда важно обеспечить их готовность без вызовов кода при старте.

ПРИМЕЧАНИЕ

Singleton по методу Мейера использует статическую локальную переменную, инициализация которой происходит при первом вызове функции, обеспечивая потокобезопасности в C++11 и выше благодаря гарантиям инициализации таких переменных.

ВАЖНО

Рекомендуется протестировать сборку с использованием флага -fsanitize=undefined, чтобы выявить неопределённое поведение в коде.

Константная память неизменяемые сокровища

Константная память это read-only часть статики, где хранятся неизменяемые данные: строковые литералы ("hello"), const глобальные и иногда константы времени компиляции. Представьте библиотеку с древними свитками: вы можете читать, но не писать ОС защищает страницы от записи (page protection). Это предотвращает ошибки (segfault при попытке модификации) и оптимизирует (данные shared между процессами).

В ELF/PE файлах .rodata сегмент. Строки: const char* s = "hi"; s указывает в RO. Модификация UB. Идеально для таблиц, конфигов, математических констант.

Пример:

#include <iostream>

const int CONST_GLOBAL = 100; // Может быть в .data или .rodata

int main() {

const char* str = "Immutable"; // В RO

// str[0] = 'X'; // UB, segfault

std::cout << str << std::endl;

return 0;

}

НА ЗАМЕТКУ

Выражение с constexpr вычисляется на этапе компиляции и может быть размещено либо в read-only-секции (RO), либо непосредственно влито в код как непосредственное значение.

ПРИМЕЧАНИЕ

Встроенное программное обеспечение с размещением исполняемого кода в энергонезависимой памяти (ROM/Flash) способствует энергосбережению за счёт отсутствия необходимости загрузки данных в оперативную память и снижения энергозатрат на выполнение операций чтения.

ВАЖНО

Не следует приводить (кастовать) константность к изменяемому типу, даже если код компилируется – это вызывает неопределённое поведение (UB), поскольку нарушает контракт const-correctness, заложенный в язык, и может привести к непредсказуемым последствиям во время выполнения.

ДОПОЛНЕНИЕ

Пулинг строк – это механизм, при котором одинаковые строковые литералы могут разделять один и тот же адрес в памяти, что позволяет экономить ресурсы за счёт избежания дублирования идентичных значений.

ВОДА

Константная память как музейные экспонаты: смотрите, но не трогайте иначе alarm!

Попытки модификации констант и игнор RO

Кастинг const: const char* s = "hi"; (char)s = 'X'; UB, crash. Или большие константы на стеке вместо RO трата стека. Забывать const данные mutable, риски коррупции.

НА ЗАМЕТКУ

В старом коде объявление char* s = "hi"; считается устаревшим (deprecated), поскольку строковый литерал "hi" имеет тип const char[N], и присваивание его неконстантному указателю char* нарушает правила const-корректности; в современных стандартах C++ это недопустимо, а в C попытка модификации такого литерала ведёт к неопределённому поведению (UB), так как строковые литералы могут размещаться в защищённой от записи памяти.

ПРИМЕЧАНИЕ

Регистр только для чтения (RO) защищён аппаратной защитой от записи (hardware write protect).

ВАЖНО

Функция безопасности «только чтение» (RO) предотвращает внедрение вредоносного кода, ограничивая возможность записи или изменения данных в защищённой области памяти.

Использование

const, constexpr

и

string_view

Всегда const для неизменяемых. Для строк: std::string_view sv = "hi"; без копии. Constexpr для compile-time: constexpr int factorial(int n) { … }

Пример:

#include <string_view>

#include <iostream>

constexpr int MAX = 100;

int main() {

std::string_view sv = "Constant view";

std::cout << sv << std::endl;

return 0;

}

НА ЗАМЕТКУ

std::string_view из C++17 – это лёгковесное, не владеющее представление строки, предоставляющее только чтение (read-only view) над последовательностью символов без копирования данных.

ПРИМЕЧАНИЕ

Массив const int arr[] = {1, 2}; размещается в секции памяти только для чтения (RO), так как объявлен как константный и инициализирован литералами во время компиляции.

ВАЖНО

Компилятор оптимизирует использование const inline, внедряя его значение непосредственно в код и устраняя избыточные обращения к переменной.

Виртуальная память контекст для реального мира

Виртуальная память (Virtual Memory) – это хитрый трюк операционной системы, который позволяет вашей C++-программе думать, что у неё в распоряжении огромный, непрерывный блок памяти, даже если физическая RAM ограничена. Представьте, что вы король в замке: виртуальная память создаёт иллюзию бесконечных земель, но на деле ОС (как мудрый управляющий) жонглирует реальными ресурсами, скрывая от вас детали. Мы разберём это минимально, только для контекста, чтобы понять, почему адреса в вашем коде не "настоящие" и как это влияет на C++.

В основе лежит адресное пространство процесса: каждый процесс (ваша программа) получает виртуальное адресное пространство от 0 до максимума (4 ГБ в 32-битных системах, до 128 ТБ или больше в 64-битных). Когда вы пишете int* ptr = new int;, ptr содержит виртуальный адрес. ОС маппит (отображает) эти виртуальные адреса на физическую память (RAM) или даже на диск, используя страницы фиксированные блоки, обычно 4 КБ. Если физической памяти не хватает, ОС "свопит" (swapping) страницы на диск (файл подкачки, swap file), освобождая RAM для активных данных. Когда программа обращается к свайпнутой странице, ОС возвращает её в RAM это называется page fault, и оно может замедлить программу (диск медленнее RAM в тысячи раз).

Для C++ это значит:

Все адреса, которые вы видите (&x, ptr), виртуальные. Они уникальны для вашего процесса и не конфликтуют с другими программами.

Выделение памяти (new, malloc) запрашивает у ОС виртуальные страницы. Если ОС не может предоставить (Out Of Memory, OOM), new бросит std::bad_alloc.

Виртуальная память предотвращает фрагментацию: даже если физическая память "дырявая" (фрагментирована), ваш код видит непрерывный блок.

НА ЗАМЕТКУ

В контейнерах, таких как Docker, или виртуальных машинах виртуальная память может быть ограничена квотами, что приводит к преждевременным ошибкам нехватки памяти (OOM), даже если физическая память системы ещё не исчерпана.

ПРИМЕЧАНИЕ

Современные операционные системы применяют стратегию overcommit – выделяют процессам больше виртуальной памяти, чем физически доступно, полагаясь на то, что не все выделенные страницы будут использованы одновременно; однако если суммарный запрос памяти превысит доступные ресурсы, механизм OOM killer в Linux автоматически завершит один или несколько процессов, чтобы предотвратить системный крах.

ВАЖНО

В C++ не следует полагаться на конкретные адреса памяти, поскольку они являются виртуальными и меняются при каждом запуске программы благодаря механизму ASLR (Address Space Layout Randomization), который обеспечивает защиту от эксплойтов, случайным образом размещая участки памяти.

ДОПОЛНЕНИЕ

Виртуальная память использует таблицы страниц – структуры данных, в которых операционная система хранит отображения виртуальных адресов в физические. При обращении к памяти процессор с помощью модуля управления памятью (MMU) преобразует виртуальные адреса в физические «на лету», что вносит небольшие накладные расходы, но обеспечивает изоляцию процессов: один процесс не может получить доступ к памяти другого.

ВОДА

Виртуальная память подобна волшебному кошельку: создаётся иллюзия неограниченных ресурсов, но при чрезмерном расходовании система вынуждена выгружать данные на диск (своп), что резко замедляет работу, как пробка в трафике, а в крайнем случае – исчерпав всю доступную память – происходит сбой из-за нехватки памяти (OOM), и программа аварийно завершается.

Зависимость от виртуальной памяти без лимитов и понимания

Многие программисты игнорируют виртуальную память, выделяя гигантские блоки на куче (new char[1e10] ;), полагаясь на своп. Результат: программа "зависает" от постоянных page faults и дисковых операций, как если бы вы пытались читать книгу, где страницы хранятся в подвале, и каждый раз бегаете за ними. Ещё хуже: не обрабатывать OOM, приводя к внезапным крашам без сообщений. Или предполагать фиксированные адреса, что ломается из-за ASLR.

НА ЗАМЕТКУ

В высоконагруженных системах, таких как серверы, чрезмерное использование подкачки (swap) может резко снизить производительность всего сервера, поскольку операции обмена данными между оперативной памятью и диском значительно медленнее, чем прямая работа с RAM, что приводит к серьёзному замедлению обработки запросов и ухудшению отзывчивости системы.

ПРИМЕЧАНИЕ

Во встраиваемых и реального времени системах, таких как автомобильная электроника или робототехника, подкачка (своп) обычно отключена из-за отсутствия дискового хранилища, поэтому исчерпание оперативной памяти (OOM) приводит к критическому сбою.

ВАЖНО

Игнорирование виртуальной природы адресов может создать уязвимости, поскольку злоумышленники способны использовать предсказуемость адресов для осуществления атак.

Мониторинг, лимиты и осознанное использование

В C++ всегда обрабатывайте исключения от выделения: new бросает std::bad_alloc при OOM (в отличие от malloc, который возвращает nullptr). Используйте try-catch для грациозного выхода. Для контейнеров вроде std::vector применяйте reserve() заранее, чтобы проверить доступность памяти. Мониторьте использование: в коде можно использовать ОС-API (например, getrusage в Unix) для трекинга виртуальной памяти.

Пример обработки OOM:

#include <iostream>

#include <new> // Для std::bad_alloc

int main() {

try {

auto ptr = new int[10000000000ULL]; // Попытка выделить ~40 ГБ

// Если успешно используйте

} catch (const std::bad_alloc& e) {

std::cerr << "Ошибка выделения памяти: " << e.what() << std::endl;

// Грациозный выход: очистка, логи

return 1;

}

// … остальной код

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 <iostream>

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 <iostream>

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 <iostream>

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 <iostream>

#include <cstddef> // Для offsetof

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: Адрес элемента массива

Вы должны написать программу, которая демонстрирует размещение элементов массива в памяти и показывает, что элементы целочисленного массива располагаются последовательно. Для этого объявите массив из трёх целых чисел и выведите адреса первых двух элементов. Программа должна наглядно подтвердить, что адреса отличаются на размер одного элемента (в байтах), что является следствием непрерывного хранения массива в памяти.

Подсказка: используйте оператор взятия адреса & для получения адресов элементов массива. При выводе через std::cout адреса будут отображены в шестнадцатеричном виде. Обратите внимание, что разность между адресами arr[1] и arr[0] должна быть равна sizeof(int), что подтверждает последовательное размещение элементов в памяти.

Задача 5: Размер структуры с padding

Вы должны вычислить размер в байтах структуры, содержащей один элемент типа char, за которым следует элемент типа int. Учтите, что компилятор выравнивает поля структуры в памяти так, чтобы каждый элемент начинался по адресу, кратному его собственному размеру. Это может привести к вставке дополнительных неиспользуемых байтов (padding) между полями или в конце структуры, чтобы удовлетворить требованиям выравнивания. Не предполагайте, что размер структуры равен сумме размеров её полей – вместо этого рассмотрите, как именно происходит выравнивание на типичной архитектуре, где sizeof(int) равен 4.

Подсказка: после поля char c (размер 1 байт) компилятор вставляет 3 байта выравнивания, чтобы поле int i начиналось с адреса, кратного 4. Таким образом, первые 4 байта содержат c и padding, следующие 4 байта – значение i. Общий размер структуры выравнивается также до кратного размеру её самого выравниваемого поля, что в итоге даёт 8 байт.

Задача 6: Выравнивание структуры

Вы должны определить выравнивание структуры, содержащей сначала int, а затем double. Выравнивание структуры определяется требованием её самого строго выровненного члена – то есть элемента, которому нужно начинаться по адресу, кратному наибольшему значению. Поскольку double обычно требует 8-байтового выравнивания, а int – 4-байтового, выравнивание всей структуры будет равно 8. Это значение гарантирует, что при размещении массива таких структур каждый экземпляр начинается по адресу, подходящему для хранения double.

Подсказка: функция alignof возвращает степень выравнивания типа в байтах, которая совпадает с выравниванием его самого требовательного поля. В большинстве реализаций sizeof(double) равен 8, и его выравнивание также 8, поэтому alignof(S) возвращает 8, даже если int занимает меньше места и имеет меньшее требование выравнивания.

Задача 7: Адрес глобальной переменной

Вы должны написать программу, в которой объявляется глобальная переменная и локальная переменная внутри функции main. Затем необходимо вывести на экран адреса обеих переменных с помощью оператора взятия адреса (&). Программа должна наглядно демонстрировать размещение глобальных и локальных переменных в памяти: глобальная переменная располагается в сегменте данных, а локальная – в стеке, поэтому их адреса будут различаться, причём адрес глобальной переменной обычно меньше, чем у локальной.

Подсказка: объявите одну переменную вне всех функций (глобально), а другую – внутри main. Используйте std::cout и оператор & для вывода их адресов. Не забудьте подключить заголовок .

Задача 8: Размер массива vs указателя

Вы должны понять, почему применение оператора sizeof к имени массива даёт общий размер всех его элементов в байтах, в то время как применение того же оператора к указателю, даже если он указывает на начало этого массива, возвращает лишь размер самого адреса в памяти. Это связано с тем, что имя массива в большинстве контекстов неявно преобразуется в указатель на первый элемент, но при использовании sizeof такое преобразование не происходит – компилятор видит массив как фиксированный блок памяти известного на этапе компиляции размера. Указатель же всегда остаётся просто переменной, хранящей адрес, и его размер определяется разрядностью системы.

Подсказка: вспомните, как язык C++ трактует имя массива в контексте sizeof, и чем принципиально отличается тип int[5] от типа int*. Обратите внимание на то, что sizeof вычисляется на этапе компиляции и зависит от статического типа операнда, а не от его значения во время выполнения.

Задача 9: Выравнивание с alignas

Вы должны объявить переменную так, чтобы её выравнивание в памяти составляло ровно 16 байт, не полагаясь на естественное выравнивание её типа. Для этого используйте спецификатор выравнивания, который гарантирует, что адрес переменной будет кратен указанной степени двойки. После объявления выведите запрошенное выравнивание с помощью оператора, предназначенного для запроса выравнивания типа или объекта.

Подсказка: встроенная конструкция языка позволяет задать выравнивание при объявлении переменной, а другая – узнать, какое выравнивание применяется к объекту или типу. Убедитесь, что вы используете именно ту, что возвращает требуемое значение, а не размер или что-то иное.

Задача 10: Адрес в функции

Вы должны написать программу, в которой определяется функция без параметров. Внутри этой функции объявляется локальная переменная целочисленного типа и инициализируется значением 20. Сразу после объявления переменной программа должна вывести на экран её адрес в памяти с поясняющим текстом. Основная функция должна лишь вызывать эту вспомогательную функцию и завершаться.

Подсказка: локальные переменные размещаются в стековой памяти, и их адреса доступны только в течение выполнения функции, в которой они объявлены. Используйте оператор взятия адреса & для получения адреса переменной и выводите его с помощью std::cout.

Задача 11: Размер класса с виртуальными функциями

Вы должны написать программу, которая определяет и выводит размер объекта класса, содержащего хотя бы одну виртуальную функцию. Объявите класс с приватной или публичной виртуальной функцией без параметров и тела, создайте в функции main экземпляр такого класса (или используйте оператор sizeof без создания объекта) и выведите его размер с помощью std::cout. Убедитесь, что вывод соответствует размеру указателя на виртуальную таблицу на вашей платформе.

Подсказка: виртуальные функции заставляют компилятор неявно добавлять в каждый объект скрытый указатель на таблицу виртуальных методов (vptr). На 64-битных системах этот указатель занимает 8 байт, поэтому даже пустой класс с виртуальной функцией будет иметь размер 8. Не пытайтесь добавлять поля – размер определяется исключительно наличием механизма динамического полиморфизма.

Задача 12: Выравнивание массива

Вы должны написать программу, которая определяет выравнивание массива из десяти элементов типа double. Для этого объявите массив указанного типа и размера, затем используйте оператор alignof, чтобы получить требуемое выравнивание в байтах и выведите результат на стандартный поток вывода. Убедитесь, что ваш код соответствует стандарту C++ и не содержит лишних элементов или вычислений.

Подсказка: выравнивание массива определяется выравниванием его элементов, а не его размером. Тип double на большинстве платформ имеет выравнивание в 8 байт, и массив наследует это выравнивание. Используйте оператор alignof непосредственно к имени массива, как к типу, чтобы получить корректное значение без дополнительных манипуляций.

Задача 13: Адрес на куче

Вы должны написать программу, которая динамически выделяет память для одного целого числа, инициализирует его значением 30 и выводит на экран адрес этого участка памяти. Используйте оператор new для размещения переменной в куче (heap), а после вывода адреса корректно освободите выделенную память с помощью delete, чтобы избежать утечки ресурсов.

Подсказка: объявите указатель на int, выделите память с помощью new и сразу инициализируйте её значением 30 в скобках. Выведите значение указателя (адрес), а не то, на что он указывает. Не забудьте в конце освободить память – это не только хорошая практика, но и требование корректной работы с динамической памятью в C++.

Задача 14: Размер union

Вы должны написать программу, которая определяет и выводит размер в байтах объединения (union), содержащего как минимум два поля разного типа – например, один символ и одно целое число. Объединение устроено так, что все его члены разделяют одну и ту же область памяти, и его размер определяется наибольшим из размеров его членов с учётом выравнивания. Ваш код должен использовать оператор sizeof и вывести результат в формате "sizeof(U): X", где X – вычисленный размер.

Подсказка: объявите union с полями char и int, затем примените sizeof к типу этого объединения. Учтите, что на большинстве платформ размер int составляет 4 байта, а char – 1 байт, но из-за требований выравнивания объединение примет размер самого крупного элемента. Выведите результат с помощью std::cout, строго соблюдая указанный формат вывода.

Задача 15: Выравнивание bit-field

Вы должны определить, чему равен результат оператора alignof для структуры B, содержащей битовое поле int x:4 и поле char y. Вспомните, как выравнивание работает для структур с битовыми полями: выравнивание структуры определяется выравниванием её самого строгого (наиболее выровненного) члена. Учтите, что даже если битовое поле занимает всего несколько бит, его базовый тип (int) всё ещё влияет на выравнивание структуры. Посколь耙йте, как компилятор размещает такие поля в памяти, и соотнесите это с требуемым выравниванием для int.

Подсказка: несмотря на то, что char y требует выравнивания в 1 байт, наличие битового поля с базовым типом int заставляет компилятор выровнять всю структуру по границе int, которая обычно составляет 4 байта. Обратите внимание, что alignof возвращает выравнивание типа, а не его размер, и что выравнивание структуры не может быть меньше выравнивания любого из её членов, включая базовые типы битовых полей.

Задача 16: Сравнение адресов

Вы должны написать программу, в которой определяется разность адресов двух полей структуры, содержащей два целочисленных члена. Объявите структуру с именем S, содержащую два поля типа int – a и b. Создайте экземпляр этой структуры в функции main, а затем выведите на экран разность адресов полей b и a, используя оператор взятия адреса &. Обратите внимание: разность указателей одного типа вычисляется в количестве элементов между ними, а не в байтах.

Подсказка: хотя между полями a и b в памяти действительно находится 4 байта (размер int на большинстве платформ), арифметика указателей автоматически делит реальное смещение в байтах на размер типа, к которому относятся указатели. Поэтому выражение &s.b – &s.a вернёт 1, а не 4. Убедитесь, что ваш код выводит именно этот результат, не пытаясь вручную умножать или делить смещение – всё делается средствами языка.

Задача 17: Размер с packed

Вы должны написать программу, которая определяет размер структуры, содержащей один символ и одно целое число, при условии, что компилятор не вставляет дополнительные байты выравнивания между полями. Для этого используйте директиву управления выравниванием, чтобы упаковать поля структуры в памяти без промежутков. Выведите размер этой структуры в байтах с помощью оператора sizeof.

Подсказка: в C++ выравнивание полей структуры по умолчанию добавляет «пустые» байты между полями разного размера, чтобы ускорить доступ к данным. Чтобы отключить это поведение, примените #pragma pack(1) перед объявлением структуры и #pragma pack() после неё. Вспомните, что char занимает 1 байт, а int – обычно 4 байта на большинстве платформ.

Задача 18: Выравнивание over-aligned

Вы должны написать программу, в которой объявляется структура с выравниванием, превышающим естественное выравнивание её полей. Используйте спецификатор выравнивания alignas, чтобы гарантировать, что размер выравнивания структуры будет равен заданному значению – в данном случае 32 байтам. В функции main выведите значение, возвращаемое оператором alignof для этой структуры, чтобы убедиться, что компилятор действительно применяет указанное выравнивание.

Подсказка: стандартный тип int обычно имеет выравнивание 4 байта, но это не мешает вам явно запросить более строгое выравнивание для содержащей его структуры. Оператор alignof возвращает требуемое выравнивание типа в байтах, и его результат можно напрямую вывести с помощью std::cout. Убедитесь, что вы подключили заголовок <iostream> для работы с выводом.

Задача 19: Адрес строки

Вы должны написать программу, которая выводит адрес строкового литерала в памяти. Объявите указатель на константную строку и инициализируйте его строкой "hello". Затем выведите значение этого указателя, явно приведя его к типу void*, чтобы избежать интерпретации как строки. Не забудьте корректно завершить вывод с помощью std::endl.

Подсказка: строковые литералы в C++ размещаются в секции памяти только для чтения, поэтому их адрес нельзя изменить. Чтобы std::cout не пытался напечатать содержимое строки, а показал именно адрес, требуется приведение к void*. Убедитесь, что синтаксис вывода корректен – в частности, проверьте количество двоеточий в std::endl.

Задача 20: Комбинированная: sizeof, alignof, &

Вы должны написать программу, которая выводит три значения для структуры M, содержащей поля short s и long l: её размер в байтах, выравнивание в памяти и разницу в байтах между адресами этих двух полей. Используйте операторы sizeof и alignof, а также приведение адресов полей к char* для вычисления смещения между ними. Убедитесь, что вывод соответствует поведению компилятора на платформе с 8-байтовым выравниванием для типа long.

Подсказка: компилятор может вставлять промежуточные байты между полями структуры, чтобы удовлетворить требованиям выравнивания. Адрес каждого поля можно получить с помощью оператора взятия адреса &, а разницу между ними – приведя оба адреса к char* и вычтя один из другого. Помните, что порядок полей в структуре совпадает с их объявлением, и выравнивание всей структуры определяется самым строгим выравниванием среди её полей.

Резюме

В этой главе мы заложили фундамент понимания памяти в C++: разобрали сегменты (стек, куча, статика, код и константная память), их особенности, анти-паттерны и лучшие практики. Узнали о виртуальной памяти для контекста, выравнивании (alignof), размерах (sizeof vs реальный с padding), жизненном цикле объектов и динамическом выделении с RAII. Практика с &, sizeof и alignof помогла закрепить теорию. Теперь вы готовы к глубокому погружению в указатели и ссылки они строятся на этих основах. Помните: правильное управление памятью ключ к надёжному коду!

Ответы на задачи

Ответы Глава 1. Память: стек, куча, статика

Ответ на задачу 1 (Адрес переменной на стеке)

#include <iostream>

int main() {

int x = 10;

std::cout << "Адрес x: " << &x << std::endl;

return 0;

}

(Вывод: адрес в hex-формате, например, 0x7ffc12345678)

Ответ на задачу 2 (Размер простого типа)

#include <iostream>

int main() {

std::cout << "Размер int: " << sizeof(int) << " байт" << std::endl;

return 0;

}

(Вывод: 4 байт)

Ответ на задачу 3 (Выравнивание базового типа)

#include <iostream>

int main() {

std::cout << "Выравнивание double: " << alignof(double) << " байт" << std::endl;

return 0;

}

(Вывод: 8 байт)

Ответ на задачу 4 (Адрес элемента массива)

#include <iostream>

int main() {

int arr[3] = {1, 2, 3};

std::cout << "Адрес arr[0]: " << &arr[0] << std::endl;

std::cout << "Адрес arr[1]: " << &arr[1] << std::endl;

return 0;

}

(Вывод: адреса, разница 4 байта)

Ответ на задачу 5 (Размер структуры с padding)

#include <iostream>

struct S { char c; int i; };

int main() {

std::cout << "sizeof(S): " << sizeof(S) << std::endl;

return 0;

}

(Вывод: 8 байт)

Ответ на задачу 6 (Выравнивание структуры)

#include <iostream>

struct S { int i; double d; };

int main() {

std::cout << "alignof(S): " << alignof(S) << std::endl;

return 0;

}

(Вывод: 8 байт)

Ответ на задачу 7 (Адрес глобальной переменной)

#include <iostream>

int g = 5;

int main() {

int l = 10;

std::cout << "Адрес g: " << &g << std::endl;

std::cout << "Адрес l: " << &l << std::endl;

return 0;

}

(Вывод: разные адреса, g ниже)

Ответ на задачу 8 (Размер массива vs указателя)

#include <iostream>

int main() {

int arr[5];

int* p = arr;

std::cout << "sizeof(arr): " << sizeof(arr) << std::endl;

std::cout << "sizeof(p): " << sizeof(p) << std::endl;

return 0;

}

(Вывод: 20 и 8)

Ответ на задачу 9 (Выравнивание с alignas)

#include <iostream>

int main() {

alignas(16) int x;

std::cout << "alignof(x): " << alignof(x) << std::endl;

return 0;

}

(Вывод: 16 байт)

Ответ на задачу 10 (Адрес в функции)

#include <iostream>

void func() {

int y = 20;

std::cout << "Адрес в func: " << &y << std::endl;

}

int main() {

func();

return 0;

}

(Вывод: адрес на стеке)

Ответ на задачу 11 (Размер класса с виртуальными функциями)

#include <iostream>

class C { virtual void f() {} };

int main() {

std::cout << "sizeof(C): " << sizeof(C) << std::endl;

return 0;

}

(Вывод: 8 байт)

Ответ на задачу 12 (Выравнивание массива)

#include <iostream>

int main() {

double arr[10];

std::cout << "alignof(arr): " << alignof(arr) << std::endl;

return 0;

}

(Вывод: 8 байт)

Ответ на задачу 13 (Адрес на куче)

#include <iostream>

int main() {

int* p = new int(30);

std::cout << "Адрес на куче: " << p << std::endl;

delete p;

return 0;

}

(Вывод: адрес на heap)

Ответ на задачу 14 (Размер union)

#include <iostream>

union U { char c; int i; };

int main() {

std::cout << "sizeof(U): " << sizeof(U) << std::endl;

return 0;

}

(Вывод: 4 байт)

Ответ на задачу 15 (Выравнивание bit-field)

#include <iostream>

struct B { int x:4; char y; };

int main() {

std::cout << "alignof(B): " << alignof(B) << std::endl;

return 0;

}

(Вывод: 4 байт)

Ответ на задачу 16 (Сравнение адресов)

#include <iostream>

struct S { int a; int b; };

int main() {

S s;

std::cout << "Разница адресов: " << (&s.b – &s.a) << std::endl;

return 0;

}

(Вывод: 1, но в указателях 4 байта)

Ответ на задачу 17 (Размер с packed)

#include <iostream>

#pragma pack(1)

struct P { char c; int i; };

#pragma pack()

int main() {

std::cout << "sizeof(P): " << sizeof(P) << std::endl;

return 0;

}

(Вывод: 5 байт)

Ответ на задачу 18 (Выравнивание over-aligned)

#include <iostream>

struct alignas(32) O { int i; }

int main() {

std::cout << "alignof(O): " << alignof(O) << std::endl;

return 0;

}

(Вывод: 32 байт)

Ответ на задачу 19 (Адрес строки)

#include <iostream>

int main() {

const char* str = "hello";

std::cout << "Адрес строки: " << (void*)str << std:endl;

return 0;

}

(Вывод: адрес в RO-data)

Ответ на задачу 20 (Комбинированная: sizeof, alignof, &)

#include <iostream>

struct M { short s; long l; };

int main() {

M m;

std::cout << "sizeof(M): " << sizeof(M) << std::endl;

std::cout << "alignof(M): " << alignof(M) << std::endl;

std::cout << "Разница адресов: " << reinterpret_cast<char*>(&m.l) – reinterpret_cast<char*>(&m.s) << std::endl;

return 0;

}

(Вывод: 16, 8, 8)