Раздел 17 — Динамическая память и связные структуры

Память, которую программа берёт и возвращает сама: массив любого размера, список, стек и очередь

Прогресс курса Раздел 17 из 28

Что вы освоите в этом разделе

3 академических часа теории и 6 часов практики: практические работы №15 «Динамический массив», №16 «Связный список» и №17 «Стек и очередь» — на портале.
01

Зачем динамическая память

До сих пор размер каждого массива был известен при компиляции: int a[1000]. Это неудобно: сколько строк в файле или чисел во вводе, становится ясно только при работе программы. Массив «с запасом» тратит память впустую и всё равно может оказаться мал. У программы на C три области для данных:

ОбластьЧто тамЖивётРазмер
статическаяглобальные и static-переменныевсё время работы программызадан при компиляции
стеклокальные переменные, параметрыдо выхода из функциизадан при компиляции; весь стек — 1 МБ в Windows, 8 МБ в Linux
куча (heap)блоки от mallocпока программа не вызовет freeлюбой, выбирается при работе; ограничен памятью компьютера

Блок в куче не привязан к функции: его можно создать в одной функции, вернуть указатель и освободить в другой. Это и даёт свободу, и создаёт ответственность: у каждого блока должен быть владелец — код, который его освободит ровно один раз.

02

malloc, calloc, realloc, free

Функции объявлены в stdlib.h и работают с размером в байтах:

Если памяти нет, первые три возвращают NULL — его проверяют каждый раз.

int *squares = malloc((size_t)n * sizeof *squares);
if (squares == NULL) {
    fprintf(stderr, "out of memory\n");
    return 1;
}
…
/* realloc: результат — во временный указатель */
int *bigger = realloc(squares, 2 * (size_t)n * sizeof *squares);
if (bigger == NULL) {
    free(squares);                       /* старый блок цел — освободить его */
    …
}
squares = bigger;
…
free(squares);
squares = NULL;                          /* висячий указатель больше не опасен */
malloc 5 ints (20 bytes): last = 16
realloc to 10 ints: first = 0, last = 81
calloc digit counts of pi: 0 2 1 3 1 3 1 1 1 3
copy_string: Nevabit (8 bytes)
malloc(SIZE_MAX / 2) = NULL

Пример целиком — examples/17/memory.c.

03

Четыре ошибки и AddressSanitizer

Компилятор следит за памятью стека, но не кучи. Ошибки с ней не видны сразу: программа часто выводит верный ответ, а падает потом или на другом компьютере. Программа examples/17/bugs.c делает четыре типичные ошибки:

ОшибкаКодБез проверкиС ASan
Утечка: блок не освобождён, указатель потерянint *a = malloc(…); в цикле без freeработает; долгоживущая программа (сервер) съедает памятьdetected memory leaks, 1200 байтов в 3 блоках, строка malloc
Висячий указатель: обращение после freefree(a); printf("%d", a[0]);печатает мусор: 1519377410 вместо 42heap-use-after-free: где читали, где освободили, где выделили
Двойное освобождениеfree(s); free(s);Linux: double free detected, аварийно (код 134); бывает и тихая порча кучиattempting double-free, обе строки free
Выход за границу блокаmalloc(strlen(s)) — забыт байт под '\0'работает: copy = Nevabitheap-buffer-overflow в strcpy

AddressSanitizer (ASan) встроен в gcc и clang: программа собирается с проверками каждого обращения к памяти и при выходе сообщает об утечках. Отладочная сборка:

gcc -std=c11 -Wall -Wextra -g -fsanitize=address,undefined bugs.c -o bugs
./bugs dangling
==1435616==ERROR: AddressSanitizer: heap-use-after-free on address 0x504000000010 …
    #0 … in dangling bugs.c:28          ← где прочитали
    …
    #1 … in dangling bugs.c:27          ← где освободили
    …
    #1 … in dangling bugs.c:22          ← где выделили
Где взять ASan. В Linux (и в WSL) — gcc из apt install gcc, ничего не нужно доставлять. В MSYS2 gcc под Windows ASan не поддерживается — есть clang в окружении CLANG64 (pacman -S mingw-w64-clang-x86_64-clang, та же строка с clang вместо gcc). Программа с ASan работает в 2–3 раза медленнее — это сборка для проверки, не для сдачи. Кроме того, gcc с -Wall замечает часть ошибок сам: pointer 'a' used after 'free', writing 8 bytes into a region of size 7 (последнее — только с -O2).

Правила, которые снимают большую часть ошибок:

04

Динамический массив

Динамический массив хранит три поля: указатель на блок, сколько элементов занято (size) и под сколько выделено место (capacity). Пока место есть, добавление — просто запись в data[size++]. Когда место кончилось, блок увеличивается realloc-ом — вдвое:

typedef struct {
    int *data;
    size_t size;                             /* занято элементов */
    size_t capacity;                         /* выделено элементов */
} IntArray;

void array_init(IntArray *a)
{
    a->data = NULL;
    a->size = 0;
    a->capacity = 0;
}

/* Добавить в конец; при нехватке места — увеличить ёмкость вдвое. Нет памяти — завершить программу */
void array_push(IntArray *a, int value)
{
    if (a->size == a->capacity) {
        size_t new_capacity = a->capacity == 0 ? 4 : a->capacity * 2;
        int *p = realloc(a->data, new_capacity * sizeof *p);
        if (p == NULL) {
            fprintf(stderr, "out of memory\n");
            array_free(a);
            exit(1);
        }
        a->data = p;
        a->capacity = new_capacity;
    }
    a->data[a->size++] = value;
}

void array_free(IntArray *a)
{
    free(a->data);
    array_init(a);
}

/* Чтение заранее неизвестного числа целых */
IntArray a;
array_init(&a);
int x;
while (scanf("%d", &x) == 1) {
    array_push(&a, x);
}

Функции получают указатель на структуру: они меняют её поля, а копия структуры (раздел 15) изменилась бы и пропала. realloc(NULL, n) работает как malloc(n) — поэтому первый рост не нужно выделять отдельно. Удаление из середины сдвигает хвост влево — memmove, за O(n).

Почему вдвое. realloc может переместить блок и скопировать всё занятое. Пример examples/17/dynarray.c считает перевыделения и элементы, которые при этом пришлось бы скопировать, при чтении чисел 1…n для трёх правил роста:

n×2: перевыделений / копий+1+1000
103 / 1210 / 451 / 0
1 0009 / 1 0201 000 / 499 5001 / 0
1 000 00019 / 1 048 57210⁶ / ≈ 5·10¹¹ (расчёт)1 000 / 499 500 000
05

Односвязный список

Массив держит элементы подряд. Связный список — цепочка отдельных блоков-узлов: в каждом значение и указатель на следующий узел. У последнего next == NULL; весь список — указатель на первый узел (голову), пустой список — NULL.

typedef struct Node {
    int value;
    struct Node *next;                     /* тип Node ещё не объявлен — пишем struct Node */
} Node;

/* В начало за O(1): голова меняется — передаём указатель на неё */
bool push_front(Node **head, int value)
{
    Node *n = malloc(sizeof *n);
    if (n == NULL) {
        return false;
    }
    n->value = value;
    n->next = *head;
    *head = n;
    return true;
}

/* Удалить первый узел со значением; указатель на ссылку убирает особый случай головы */
bool remove_value(Node **head, int value)
{
    Node **link = head;
    while (*link != NULL && (*link)->value != value) {
        link = &(*link)->next;
    }
    if (*link == NULL) {
        return false;
    }
    Node *victim = *link;
    *link = victim->next;                  /* обойти узел: перецепить ссылку */
    free(victim);
    return true;
}

/* Освободить все узлы: next запомнить до free */
void free_list(Node **head)
{
    Node *p = *head;
    while (p != NULL) {
        Node *next = p->next;
        free(p);
        p = next;
    }
    *head = NULL;
}
push_front:    30 -> 20 -> 10 -> NULL
push_back:     30 -> 20 -> 10 -> 5 -> 7 -> NULL
find 10: yes, find 99: no
remove 30 5 7: 20 -> 10 -> NULL
reverse:       2 -> 1 -> 10 -> 20 -> NULL
backward:      20 10 1 2
free_list:     NULL
ОперацияДинамический массивОдносвязный список
доступ к i-муO(1)O(i) — идти от головы
в конецO(1) в среднемO(1) с tail, иначе O(n)
в начало, удалить первыйO(n) — сдвигO(1)
вставить / удалить в известном местеO(n) — сдвигO(1) — перецепить ссылки
поиск, двоичный поискO(n); в отсортированном O(log n)O(n); двоичного нет
память на элементэлемент (+ запас ёмкости)элемент + указатель + служебные байты блока

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

06

Стек и очередь

Это не способы хранения, а правила доступа:

Стек удобно делать на динамическом массиве: вершина — data[size − 1], обе операции O(1). Очередь на массиве со сдвигом после каждого dequeue стоила бы O(n); вместо сдвига используют кольцевой буфер: начало head движется вперёд, а индексы берутся по модулю ёмкости.

typedef enum { OK, ERR_MEMORY, ERR_EMPTY, ERR_FULL } Status;

typedef struct {
    int *data;
    size_t head;                             /* индекс первого элемента */
    size_t count;
    size_t capacity;
} Queue;

Status enqueue(Queue *q, int value)
{
    if (q->count == q->capacity) {
        return ERR_FULL;
    }
    q->data[(q->head + q->count) % q->capacity] = value;   /* конец — за последним, по кругу */
    q->count++;
    return OK;
}

Status dequeue(Queue *q, int *value)
{
    if (q->count == 0) {
        return ERR_EMPTY;
    }
    *value = q->data[q->head];
    q->head = (q->head + 1) % q->capacity;
    q->count--;
    return OK;
}
queue of 4: in 1 in 2 in 3 in 4 FULL(5), out 1, out 2, in 6 7 (head = 2)
rest: 3 4 6 7

После двух dequeue начало сдвинулось на индекс 2, а 6 и 7 легли в освободившиеся ячейки 0 и 1 — по кругу. Очередь можно сделать и на списке: head для выдачи, tail для добавления, обе операции O(1), ограничения ёмкости нет.

Как оформлять функции. У pop и dequeue два результата: значение и признак успеха (пустая очередь — не значение). Принятый в C способ (раздел 15): функция возвращает код ошибки, значение кладёт по указателю. Перечисление Status делает коды читаемыми, и вызывающий решает, что делать при ошибке. exit внутри функции структуры данных — крайняя мера (как в array_push выше, где на нехватку памяти учебной программе нечего ответить): библиотечная функция не должна решать за программу, завершаться ли ей.

07

Стек в деле: скобки и выражения

Проверка скобок. Открывающую скобку кладём в стек; закрывающая должна совпасть по виду со снятой сверху. Ошибка — если стек пуст при закрывающей, вид не совпал или в конце в стеке что-то осталось. Позиции считаются с 0:

a[i] = (b + c) * {d}   ok
f(a[i)]                error at 5          ← ')' закрывает '['
((x)                   error at 4          ← конец строки, '(' не закрыта
x)                     error at 1          ← закрывать нечего

Обратная польская запись (ОПН, постфиксная): знак операции ставится после операндов — 3 4 + 2 * вместо (3 + 4) * 2. Скобки и приоритеты не нужны, а вычисление — один проход со стеком: число кладётся в стек, операция снимает два верхних (сначала правый операнд, затем левый) и кладёт результат. В конце в стеке ровно одно число — ответ.

Status eval_rpn(const char *expr, long *result);    /* код ошибки + результат через указатель */

3 4 + 2 *            = 14
5 1 2 + 4 * + 3 -    = 14
2 3                  error: too many operands
1 +                  error: too few operands
4 0 /                error: division by zero
2 x *                error: bad token

Обычную (инфиксную) запись в ОПН переводит алгоритм сортировочной станции Дейкстры — тоже стеком, но для операций: число сразу уходит в выход, операция выталкивает из стека операции не ниже своего приоритета, скобка «(» кладётся в стек, «)» выталкивает всё до «(». Так работают калькуляторы и компиляторы. Код примеров — examples/17/stack_queue.c.

Ключевые выводы раздела

Запомните главное

  • Куча — память на время, выбранное программой: malloc/calloc/realloc выделяют, free возвращает; размер — n * sizeof *p
  • Результат выделения проверяется на NULL; realloc — во временный указатель
  • Утечка, висячий указатель, двойное free, выход за блок часто не видны в выводе — их ловит ASan (-fsanitize=address); после free — p = NULL
  • Динамический массив: data, size, capacity; рост вдвое даёт добавление за амортизированное O(1), рост на константу — O(n²) копий
  • Список: узлы со ссылкой next; вставка и удаление в известном месте O(1), доступ по номеру O(n); Node ** — когда меняется голова
  • Освобождая список, сохраняй next до free
  • Стек — LIFO, очередь — FIFO; стек на массиве, очередь на кольцевом буфере или списке, все операции O(1)
  • Функция структуры данных возвращает код ошибки, результат — через указатель
  • Стек проверяет скобки и вычисляет ОПН за один проход
Связи раздела. Опирается на раздел 10 — указатели и адресная арифметика; раздел 13 — структуры; раздел 15 — передача через указатель, несколько результатов функции, static; раздел 16 — стек вызовов и рекурсия. Нужен для: раздела 18 — модуль «стек» в отдельных файлах; раздела 19 — хеш-таблица на списках и дерево поиска из узлов (ПЗ №18, 19); раздела 20 — new/delete и std::vector вместо ручного realloc; раздела 21 — динамический массив как класс с деструктором; раздела 24 — очередь для обхода графа в ширину.
Практические работы №15–17 и тест по разделу 17 — на учебном портале. Задания по вариантам, критерии оценивания и сдача — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Раздел 16: Рекурсия Все разделы Раздел 18: Модули и сборка