Как malloc и free управляют динамической памятью в C

3

Выделение памяти «на лету» — это то, как программы на C растут и сжимаются во время выполнения. Вы не знаете точных потребностей вашего приложения до тех пор, пока оно фактически не начнет выполняться. Решение заключается в использовании кучи (heap). Вы запрашиваете блок. Операционная система резервирует его. Вы используете его. Затем вы возвращаете его с помощью free. Этот цикл позволяет другим программам повторно использовать ту же самую оперативную память.

Рассмотрим следующий простой пример. Это самое базовое взаимодействие с кучей, которое только возможно.

Как на самом деле работает malloc

Функция malloc — это не магия. Это механик, проверяющий наличие инвентаря. Когда вы вызываете её, происходят три действия последовательно.

  1. Он проверяет текущую ёмкость кучи. Он смотрит на параметр, который вы передали — в данном случае sizeof(int), что составляет 4 байта. Он спрашивает, существует ли достаточно непрерывного пространства. Если куча фрагментирована или заполнена, операция завершается неудачей.
  2. Если пространство существует, он резервирует этот блок. Система помечает его как «ваш». Ни один другой вызов malloc не может коснуться этого фрагмента. Это предотвращает случайные коллизии между выделениями памяти.
  3. Он возвращает адрес этого блока. Он не даёт вам сами данные. Он даёт вам переменную-указатель. Эта переменная содержит местоположение. Теперь у вас есть ссылка на конкретное место в оперативной памяти.

В нашем примере p получает этот адрес. Переменная-указатель — это просто контейнер для адреса памяти. Фактическое целочисленное значение находится по этому адресу.

Почему необходимо проверять на NULL

Вы видите проверку if (p == 0) сразу после выделения памяти. Некоторые новички пропускают этот шаг. Это фатальная ошибка.

Всегда проверяйте указатель после любого вызова malloc, чтобы убедиться, что указатель действителен.

Размер кучи постоянно колеблется. Это зависит от того, какие другие процессы запущены. Это зависит от того, сколько памяти они захватили. Нет никаких гарантий, что вызов завершится успешно. Если malloc терпит неудачу, он возвращает ноль. Ноль — это NULL. Если вы попытаетесь разыменовать нулевой указатель, ваша программа аварийно завершит работу. Жестко.

Вы можете написать if (p == NULL) или if (!p). Они означают одно и то же. Но вы должны проверять. Каждый раз.

Что произойдет, если вы забудете освободить память?

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

Так значит ли это, что нормально «утекать» памятью в конце программы? Технически да. ОС выполняет очистку. Для машины нет долгосрочных последствий.

Но это дурной тон. И, что более важно, утечки памяти во время выполнения смертельно опасны. Если вы продолжаете выделять память, не освобождая её, вы задыхаете собственный процесс. Куча заполняется. Новые выделения терпят неудачу. Программа умирает от удушья, а не из-за аварийного завершения (crash).

Указатели против значений, на которые они указывают

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

Первый сценарий. Вы выделяете один блок. Вы указываете две переменные на него.

Вывод:
10
20

Почему? p и q — это разные указатели. Но q = p заставляет их указывать на один и тот же блок памяти. Изменение значения через p изменяет его и для q. Изменение его через q изменяет его и для p. Это две ручки одной двери.

Второй сценарий. Вы выделяете два отдельных блока.

Вывод:
20

Здесь p и q указывают на разные адреса. *q содержит 20. *p = *q копирует значение 20 в место, на которое указывает p. Сам указатель p не перемещается. Меняется значение по адресу p.

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

Указатели, память и свободное падение

Вывод программы: 20. Строка 6 говорит сама за себя.

Можно подумать, что *p = *q и p = q делают одно и то же. Это не так. Одно действие перемещает данные, другое — адреса. Компилятор разрешает присваивание *p = *q, потому что оба разыменованных указателя имеют тип int. Это прямой обмен значениями. Но p = q? Это другое. Оно заставляет p указывать на тот же блок памяти, что и q. Перемещается адрес. Данные остаются на месте.

Если типы не совпадают, компилятор блокирует операцию. Указатель на целое число не может указывать на символьную строку. Типы должны совпадать.

Четыре способа инициализации

Новая переменная-указатель является опасной, пока не инициализирована.

int *p; создает указатель, который ни на что не указывает. Он указывает на мусор. Разыменование такого указателя — ошибка. Вам нужно известное место. Есть четыре способа туда попасть.

  1. malloc : Эта функция выделяет блок в куче (heap). p теперь содержит действительный адрес. Указатель инициализирован, потому что он указывает на конкретный, зарезервированный фрагмент памяти.
  2. Присваивание : p = q; если q действителен, p становится действительным. Он наследует адрес q. Если q содержит мусор, p тоже получает мусор.
  3. Явный адрес : Вы можете направить p непосредственно на известную переменную. p = &i; направляет p на целое число i. Просто. Прямо.
  4. Ноль/NULL : p = 0; или p = NULL;. Это устанавливает адрес в ноль. Он не указывает на блок. Он указывает на нулевую позицию.

Зачем использовать ноль? Это флаг.

Вы можете проверить if (p == 0), чтобы узнать, действителен ли указатель. Система распознает это. Если вы попытаетесь разыменовать нулевой указатель, программа аварийно завершит работу. Жестко.

p = 0; *p = 5;

Вторая строка терпит неудачу. p ни на что не указывает. Вы не можете записывать данные в никуда. Это поведение критически важно для связанных списков в дальнейшем. Это страховочная сетка.

Освобождение кучи

Выделения памяти недостаточно. Вы должны вернуть её.

malloc захватывает пространство. free возвращает его.

free(p) делает две вещи. Во-первых, он отменяет резервирование блока. Куча возвращает эту память. Она может быть переиспользована. Во-вторых, p остается неинициализированным. Он не становится безопасным магическим образом. Вы должны повторно инициализировать p перед его повторным использованием.

Блок исчезает из вашей области видимости. Указатель становится «висячим» (dangling).

Вот стандартный шаблон для работы с одним целым числом в куче:

Это в основном демонстрация. Но она показывает жизненный цикл.

sizeof(int) возвращает размер в байтах. На большинстве машин это 4. Вы могли бы использовать жестко заданное значение malloc(4). Не делайте этого. sizeof делает код переносимым. Он читается лучше. Он адаптируется при изменении архитектуры.

malloc возвращает универсальный указатель. Компиляторы часто выдают предупреждение, если вы не приведете тип. (int *) преобразует этот универсальный блок в указатель на целое число. Это соответствует типу p.

free(p) возвращает блок в кучу.

Структуры и приоритет операторов

Структуры ведут себя так же. Вы выделяете блок для всей структуры, а не только для отдельных полей.

Посмотрите на (*p).i = 10;.

Зачем нужны скобки?

Потому что . имеет более высокий приоритет, чем *.

Без скобок *p.i означает *(p.i). Это пытается получить доступ к i через указатель p, а затем разыменовать результат. Это не то, что вам нужно. Вы хотите сначала разыменовать p, а затем получить доступ к i.

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

Большинство людей ненавидят многократный ввод (*p).i. Это громоздко.

В C есть сокращение.

p->i

Это в точности то же самое, что и (*p).i. Но его быстрее печатать. Вы увидите -> повсюду в коде на C. Это стандартный способ доступа к членам структуры через указатель.

Куча — это конечный ресурс. Выделяйте память тщательно. Освобождайте её последовательно. Или наблюдайте, как ваша память утекает.