Функции malloc(), free(), calloc(), realloc(), memcpy() и memmove()

Практический курс по C/C++: https://stepik.org/course/193691

До сих пор мы с вами данные программы размещали в переменных. Собственно, для этого они и существуют. Однако имеют некоторые известные нам ограничения. В частности, они располагаются либо в статической области памяти, если речь идет о глобальных переменных, и существуют на всем протяжении работы программы, либо в ограниченном по объему стековом фрейме, когда речь идет о локальных переменных. При этом вся остальная свободная память устройства, которая не занята другими процессами, работающими параллельно с нашей программой, никак не используется. Переменные в ней не размещаются (если не считать глобальных переменных, но они размещаются раз и навсегда, пока не завершится программа). Так вот, в программировании эта остальная свободная память называется «кучей» или по-английски heap. Язык Си предоставляет возможность самостоятельно выделять память в «куче», использовать ее по своему усмотрению, а затем, также самостоятельно освобождать ранее выделенную память. Для этого в заголовочном файле stdlib.h определены две основные функции:

  • void* malloc(size_t size); // для выделения size байт из кучи
  • void free(void* ptr); // для освобождения памяти по указателю ptr

Прежде чем перейти к рассмотрению этих функций, поясню смысл слов «выделение» и «освобождение» памяти. В первых компьютерах таких понятий не существовало. Была память, работала одна программа в один момент времени, и программист мог совершенно свободно использовать ее, не спрашивая ни у кого разрешения. Все было предельно просто, пока не появились они – многозадачные ОС с параллельным выполнением сразу нескольких программ. Романтике программирования был нанесен серьезный урон. Теперь программа не могла по своему собственному усмотрению размещать данные, как ей казалось, в свободных ячейках. Эти ячейки могли быть совсем не свободными, а использоваться другой программой. В результате для контроля за использованием памяти на уровне ОС был создан посредник – менеджер памяти. И отныне любая программа, которая хочет получить свой неделимый кусок из набора байт, должна обращаться к этому посреднику через системный вызов и с надеждой ожидать положительного решения своей просьбы. К счастью, просьбы чаще всего удовлетворяются за очень редкими исключениями, когда нужного запрашиваемого объема памяти недостаточно. Если менеджер памяти оказался к вам благосклонен и выдал адрес начала неделимого блока из байт, то никакая другая программа его уже не получит. Ваша программа становится полновластным хозяином над этими ячейками памяти. И только после того, как вы благосклонно освободите их, дав свободу с помощью функции free(), ими могут воспользоваться другие программы, заявляя над ними свои права. Вот что в действительности значат невинные на первый взгляд слова «выделение» и «освобождение» памяти.

Давайте посмотрим, как в программе можно воспользоваться функциями malloc() и free():

#include <stdio.h>
#include <stdlib.h>
 
int main(void) 
{
    char* ar = malloc(10);   // выделение 10 байт из кучи
    int* ptr_int = malloc(sizeof(int)); // выделение памяти под тип int
    int* ptr_short = malloc(7 * sizeof(short)); // выделение памяти под 7 элементов типа short
 
    free(ar);
    free(ptr_int);
    free(ptr_short);
    
    return 0;
}

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

Итак, первая функция malloc() запрашивает у ОС 10 байт непрерывной области и, скорее всего, получит ее, т.к. это совсем небольшой размер. Во втором вызове malloc() запрашивается число байт для хранения целочисленного значения типа int в «куче». Наконец, последний вызов возвращает непрерывную память под 7 элементов типа short. После этого выполняется освобождение ранее выделенной памяти и программа завершается.

Эффект утечки памяти

Надо сказать, современные ОС автоматически освобождают все ресурсы, которые использовались программой, при ее завершении. Поэтому, если бы мы не написали функции free(), то в данном конкретном случае никаких последствий бы не было. Но, в общем случае, и к этому нужно приучаться сразу: каждому вызову malloc() должен соответствовать один вызов функции free(). Иначе, программа выделит под себя память, но не освободит ее, когда она уже не нужна. Если к тому же функция malloc() время от времени продолжает вызываться, то выделенная для нужд программы память, будет постоянно нарастать, не освобождаясь. Такой эффект в программировании называется утечкой памяти. Это главный бич, ахиллесова пята языка программирования Си. Наверное, каждый серьезный программист, пишущий на Си, сталкивался с такой проблемой. Кажется, куда уж проще, вызвал malloc() не забудь вызвать и free(). Но это на первый взгляд. Реальные программы имеют непростую логику распределения информационных потоков. И далеко не всегда удается правильно сочетать вызовы пар функций malloc() и free(). Отсюда и проблема утечки памяти. Эта проблема настолько серьезная, что многие современные языки программирования, вроде Python, Java, C#, PHP и другие, имеют встроенный механизм автоматического освобождения памяти, когда она уже не нужна программе. Этот механизм реализован на основе, так называемого, сборщика мусора. Он анализирует все выделенные блоки памяти, и если на какую-либо не ведут программные ссылки (указатели), то делается вывод, что она не нужна и освобождается. Но в языке Си такого механизма нет. И это не удивительно, так как это нарушало бы философию данного языка:

  • доверять программисту;
  • не мешать программисту делать то, что он считает необходимым;
  • без необходимости не усложнять язык, сохранять его простоту;
  • каждая операция языка должна иметь только один способ выполнения;
  • операция должна выполняться максимально быстро, даже в ущерб переносимости языка.

Язык Си – это, своего рода, ассемблер высокого уровня и в этом смысле он уникален. Сами сборщики мусора для других языков программирования написаны на Си (точнее, на С++). Поэтому язык Си – это рабочая лошадка низкоуровневого программирования. Он предоставляет огромные возможности, но накладывает и большую ответственность на программиста. В частности, ответственность за освобождение ранее выделенных ресурсов.

Пример использования функций malloc() и free()

Давайте теперь посмотрим, в каких задачах целесообразно использовать функции malloc() и free(). Предположим, нам в программе нужно хранить температуру по дням в течение некоторого периода. Какой это период, никто не знает. Это может быть и 20 дней, а может 100, а возможно пользователю захочется хранить данные за последние 100 000 дней. Как в этом случае нам организовать хранение данных в программе, чтобы с одной стороны не занимать слишком много памяти, а с другой – разместить все необходимые данные? Как вы уже догадались, выход только один: воспользоваться функциями malloc() и free().

Логика программы будет следующей. Вначале мы объявим две переменные:

size_t capacity = 10;
size_t length = 0;

Первая capacity будет хранить максимальное число элементов в массиве, а вторая length – число сохраненных в массив значений. Пока length меньше capacity проблем никаких нет. Новые данные можно записывать по порядку в ячейки массива. Но, когда вся отведенная под массив память окажется заполненной, то сделаем «прием с переворотом», а точнее, «прием с копированием». Мы динамически выделим новый кусок памяти, скажем, в два раза большего размера, перенесем туда ранее записанные данные из прежнего массива и освободим из под него память. Это известная концепция, положенная в основу структуры данных, известной под названием динамический массив.

Идею динамического массива можно реализовать следующим образом:

#include <stdio.h>
#include <stdlib.h>
 
void* append(short* data, size_t *length, size_t *capacity, short value)
{
    if(*length >= *capacity) {
        short* ar = malloc(sizeof(short) * 2 * *capacity);
        if(ar == NULL)
            return data;
            
        (*capacity) *= 2;
        for(int i = 0;i < *length;++i)
            ar[i] = data[i];
        
        free(data);
        data = ar;
    }
 
    data[*length] = value;
    (*length)++;
    
    return data;
}
 
int main(void) 
{
    size_t capacity = 10;
    size_t length = 0;
 
    short* data = malloc(sizeof(short) * capacity);
 
    for(int i = 0; i < 11;++i)
        data = append(data, &length, &capacity, rand() % 40 - 20);
 
    printf("length = %u, capacity = %u\n", length, capacity);
 
    for(int i = 0;i < length;++i)
        printf("%d ", data[i]);
 
    free(data);
    return 0;
}

Вначале выделяется память под массив data, содержащий максимум 10 элементов типа short. Затем, выше, определена функция append() для добавления нового значения в массив data. В качестве аргументов этой функции передается сам массив, адреса переменных length и capacity и новое значение. После добавления функция возвращает адрес массива (на случай, если массив будет увеличен и его начальный адрес изменится). Сама функция append() работает очень просто. Вначале проверяется, нужно ли увеличивать существующий массив и если да, то значение capacity удваивается, создается новый массив удвоенной длины и в него поэлементно копируются значения из прежнего массива. Затем, освобождается память из под массива data и указателю data присваивается новый адрес массива ar. В последних строчках добавляется новое значение в массив и возвращает его адрес.

В самой функции main() мы добавляем 11 случайных значений, чтобы посмотреть, как будет работать алгоритм при переполнении массива. После этого на экран выводятся значения переменных length, capacity, а затем, сохраненные значения в массиве data.

После запуска программы увидим строчки:

length = 11, capacity = 20
-19 7 -6 0 -11 -16 18 18 -18 4 5

Действительно, массив был динамически увеличен при его заполнении и стал в два раза больше. Так мы реализовали идею динамического массива на языке Си с помощью функций malloc() и free().

Функции calloc(), realloc(), memcpy() и memmove()

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

- заголовочный файл stdlib.h:

  • void* calloc(size_t nmemb, size_t size);
  • void* realloc(void *ptr, size_t length);

- заголовочный файл string.h:

  • void* memcpy(void* restrict dst, const void* restrict src, size_t length);
  • void* memmove(void* dst, const void* src, size_t length);

Первая функция calloc() выделяет память под nmemb элементов, каждый размером size байт, то есть, всего nmemb*size байт, и заполняет ее нулями. Это ключевое ее отличие от функции malloc(). Во всем остальном, они идентичны. Следующая функция realloc() служит для изменения размера ранее выделенной памяти, на которую ведет указатель ptr. Новый размер задается вторым параметром length в байтах. В частности, при length=0 функция realloc() отрабатывает аналогично функции free(). Если же length больше ранее выделенной памяти, то функция сначала пытается расширить уже существующую область до большего размера, если ей это не удается, то создается новая с копированием всей прежней информации. Предыдущая область памяти автоматически освобождается.

Две последние функции определены в заголовочном файле string.h, так как предполагается их использовать с символьными массивами (строками). В частности, копировать одну строку в другую с помощью функции memcpy() или переносить один фрагмент строки в другой с помощью функции memmove(). Ключевое слово restrict в параметрах функции memcpy() указывает компилятору, что указатели dst и src уникальны и ведут каждый на свою независимую область памяти.

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

memcpy(ar, data, *length * sizeof(short));

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

void* append(short* data, size_t *length, size_t *capacity, short value)
{
    if(*length >= *capacity) {
        short* ar = realloc(data, sizeof(short) * 2 * *capacity);
        if(ar == NULL)
            return data;
 
        (*capacity) *= 2;
        data = ar;
    }
 
    data[*length] = value;
    (*length)++;
    
    return data;
}

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

Практический курс по C/C++: https://stepik.org/course/193691

Видео по теме