Синтаксис си/с++

Динамическая память: malloc/free, new/delete, отличия и подводные камни

Введение

До этой статьи все примеры использовали переменные с автоматическим временем жизни, размер и количество которых известны на этапе компиляции — динамическая память позволяет выделять память во время выполнения программы, размер и количество которой могут зависеть от данных, известных только во время работы программы, и в этой статье разберём оба основных механизма управления такой памятью: C-функции malloc/free и C++ операторы new/delete.

Концепция

malloc() выделяет блок памяти заданного размера в байтах, возвращая void* на начало этого блока (или nullptr при неудаче), без какой-либо инициализации содержимого — free() возвращает ранее выделенный через malloc блок памяти обратно системе, делая его доступным для последующего повторного использования. new (C++) выделяет память И автоматически вызывает конструктор объекта соответствующего типа — delete сначала вызывает деструктор объекта, а затем освобождает память, что принципиально отличает их от malloc/free, работающих исключительно с «сырой» памятью без какого-либо понятия об объектах и их жизненном цикле. Смешивание этих двух механизмов (выделение через malloc, освобождение через delete, или наоборот) является неопределённым поведением и категорически недопустимо.

Пример кода

// malloc/free — чистый C-стиль, без понятия конструктора/деструктора
#include <stdlib.h>
#include <stdio.h>

int main()
{
    int *numbers = (int *)malloc(5 * sizeof(int)); // выделение памяти для 5 int — без инициализации значений!
    if (numbers == NULL) {
        // malloc может вернуть NULL при невозможности выделить память — ОБЯЗАТЕЛЬНАЯ проверка
        return 1;
    }

    for (int i = 0; i < 5; ++i) {
        numbers[i] = i * 10; // значения изначально НЕ ОПРЕДЕЛЕНЫ — обязательно явная инициализация
    }

    printf("%dn", numbers[2]);
    free(numbers); // возврат памяти системе — после ЭТОГО numbers становится "висячим" указателем
    return 0;
}
// new/delete — C++ стиль, с автоматическим вызовом конструктора/деструктора
#include <iostream>

class Resource {
public:
    Resource() { std::cout << "Конструктор Resource вызван" << std::endl; }
    ~Resource() { std::cout << "Деструктор Resource вызван" << std::endl; }
};

int main()
{
    Resource *res = new Resource(); // выделение памяти И вызов конструктора — в ОДНОЙ операции
    delete res; // вызов деструктора И освобождение памяти — в ОДНОЙ операции

    int *dynamicArray = new int[10]; // динамический массив — ОБЯЗАТЕЛЬНО delete[], а не просто delete!
    for (int i = 0; i < 10; ++i) {
        dynamicArray[i] = i;
    }
    delete[] dynamicArray; // [] обязателен для массивов, выделенных через new[]

    return 0;
}
// Классическая ошибка — утечка памяти из-за забытого освобождения
void memoryLeak()
{
    int *leaked = new int(42);
    // ЗАБЫТО: delete leaked; — память остаётся выделенной до конца работы программы,
    // хотя указатель leaked уже недоступен (вышел из области видимости при выходе из функции)
}

Пояснения к коду

C-пример показывает явную, обязательную проверку результата malloc() на NULL — в отличие от new в C++ (по умолчанию выбрасывающего исключение std::bad_alloc при неудаче, а не возвращающего нулевой указатель), malloc сигнализирует о неудаче именно через возврат NULL, и игнорирование этой проверки перед использованием результата может привести к разыменованию нулевого указателя при последующей попытке записи значений в numbers[i]. Сравнение new Resource()/delete res с явным выводом из конструктора/деструктора демонстрирует ключевое архитектурное отличие от malloc/free — C++ автоматически связывает выделение памяти с инициализацией объекта (и освобождение с финализацией), что является основой философии RAII, систематически разбираемой далее в цикле. Различие между delete/delete[] для одиночного объекта и массива, выделенных через new/new[] соответственно — критичная деталь синтаксиса, не имеющая прямого, столь же строгого аналога в malloc/free (где free() универсален для любого блока, выделенного через malloc, независимо от того, концептуально представлял ли он единичный объект или массив).

Подводные камни

  • Смешивание malloc/free с new/delete (выделение через один механизм, освобождение через другой) — это категорически недопустимо и является неопределённым поведением, поскольку оба механизма могут использовать разные внутренние структуры учёта выделенной памяти, и попытка, например, освободить через free() память, выделенную через new (которая, помимо прочего, не вызвала бы соответствующий деструктор объекта), может привести к повреждению внутренних структур управления памятью, крашу программы, или иным труднодиагностируемым проблемам.
  • Использование delete вместо delete[] для массива, выделенного через new[] (или наоборот) — это тоже неопределённое поведение, поскольку delete[] должен вызвать деструктор для КАЖДОГО элемента массива перед освобождением всего блока памяти, тогда как обычный delete вызвал бы деструктор только для предполагаемого единственного объекта, что для массива объектов с нетривиальными деструкторами (выполняющими реальную, значимую работу по освобождению ресурсов) приведёт к тому, что деструкторы большинства элементов массива вовсе не будут вызваны.
  • Утечка памяти из-за забытого free/delete, как показано в примере memoryLeak() — память, выделенная динамически, но не освобождённая явно, остаётся занятой до завершения всего процесса (а не до выхода из функции/блока, как для автоматических переменных), и для долгоработающих программ (особенно с повторяющимся выделением памяти в цикле или часто вызываемой функции) накопление таких утечек может привести к постепенному истощению доступной памяти и, в конечном счёте, к аварийному завершению или существенной деградации производительности всей системы.
  • Использование указателя после его освобождения (use-after-free) — обращение к памяти через указатель, уже переданный в free()/delete, является неопределённым поведением, поскольку эта память могла быть переиспользована системой для совершенно других целей, и классическая защитная мера (хотя и не устраняющая саму проблему архитектурно, лишь снижающая вероятность тихого, незамеченного использования невалидного указателя) — явное присваивание nullptr указателю немедленно после освобождения, что превращает потенциальное use-after-free в явное, легко обнаруживаемое разыменование нулевого указателя при последующей ошибочной попытке использования.