Динамические массивы неразрывно связаны с указателями и динамической памятью.

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

Проблема статических массивов

При объявлении обычного массива его размер должен быть известен заранее.

Размер такого массива фиксирован. Изменить количество элементов после создания нельзя.

Это становится проблемой в ситуациях, когда размер массива заранее неизвестен.

Например, количество элементов может вводить пользователь.

const int size1 = 5;
int numbers1[size1];
int size2;
std::cin >> size2;
int numbers2[size2]; // expression must have a constant value

Или размер данных может зависеть от внешнего источника.

Например, программа может получить информацию из файла или по сети о том, что дальше будет передан пакет определённого размера.

В таком случае необходимый объём памяти становится известен только во время выполнения программы.

Для решения подобных задач используется динамическая память.

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

Для выделения памяти под один объект можно использовать оператор new:

int* number = new int;
std::cout << number << std::endl;
std::cout << *number << std::endl;
000001FE674C1A70
-1627168432

Оператор new выполняет две основные операции:

  1. выделяет память;
  2. возвращает адрес выделенной области памяти.

Этот адрес сохраняется в указателе number.

Поэтому при выводе указателя мы получим адрес, а не значение int.

Чтобы обратиться к самому значению, используется разыменование.

Адрес, возвращённый корректным вызовом new, является валидным, поэтому разыменование такого указателя допустимо.

Результат может быть разным при разных запусках программы.

Это связано с тем, что выделенная память до присваивания содержит неопределённое значение и попытка разыменовать *number приводит к неопределённому поведению.

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

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

Указатель всё ещё остаётся на стеке

int main() {
    int stackNumber = 123;
    int* number = new int;
    std::cout << &number << std::endl;
    std::cout << number << std::endl;
    std::cout << &stackNumber << std::endl;
    return 0;
}
000000D67298F950
0000021ADDC07100
000000D67298F958

В этом случае мы помимо вывода адреса, на который указывает number можем вывести адрес самого указателя number используя &.

Обратите внимание на адреса 000000D67298F958 и 000000D67298F950 это адреса переменной stackNumber и указателя number соответственно.

Они лежат в одной и той же области памяти - на стеке, а вот адрес 0000021ADDC07100 существенно от них отличается, потому что динамическая память выделяется в куче.

Инициализация памяти через new

Если необходимо получить гарантированное нулевое значение - память можно явно инициализировать.

int* ptr = new int();
int* ptr2 = new int{0};
int* ptr3 = new int{};

std::cout << *ptr << std::endl;
std::cout << *ptr2 << std::endl;
std::cout << *ptr3 << std::endl;
0
0
0

Во всех случаях значение будет равно 0.

Стек

При работе с динамической памятью важно понимать разницу между стеком и кучей.

Нужно сделать предупреждение - стандарт C++ формально не определяет память через понятия "стек" и "куча".

Это детали реализации, но в нашем случае с Windows+x64 и большинстве современных ОС используется именно эта реализация.

Стек - это область памяти, в которой обычно размещаются локальные переменные, параметры функций и служебная информация, связанная с вызовами функций.

void print() {
    int x = 10;
}

Переменная x является локальной переменной функции и обычно размещается на стеке.

Память стека управляется автоматически.

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

Размер стека ограничен.

int main() {
//Unhandled exception at 0x00007FF6EB042057 in Sandbox.exe: 
//0xC00000FD: Stack overflow (parameters: 0x0000000000000001, 0x00000073F5203000).
    volatile int arr[1024 * 1024 * 128];

    arr[0] = 1;

    std::cout << arr[0] << '\n';
}

Поэтому слишком большой статический массив может привести к ошибке переполнения стека.

Это одна из причин, по которой большие объёмы данных нельзя бездумно размещать на стеке.

Тут используется модификатор volatile, чтобы исключить оптимизации компилятора.

В MSVC если мы уберём volatile, то код скомпилируется и выполнится, ошибки стека не возникнет.

Это произойдёт потому, что компилятор видит очевидный и ожидаемый результат выполнения - вывод единицы в консоль.

Компилятор это оптимизирует и в выводе декомпилятора вы увидите следующее.

int __fastcall main(int argc, const char **argv, const char **envp)
{
  __int64 v3; // rax

  v3 = std::basic_ostream<char,std::char_traits<char>>::operator<<(std::cout, 1i64, envp);
  sub_1400010A0(v3);
  return 0;
}

То есть это буквально вывод единицы без какого-либо массива.

Куча

Куча - это область динамической памяти процесса, из которой программа получает память во время выполнения для объектов с произвольным временем жизни.

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

Для этого используются new и malloc.

malloc пришёл из языка C и в современном C++ используется значительно реже, в основном в старом или совместимом с C коде.

Оператор new также является относительно низкоуровневым механизмом.

При использовании обычного new мы должны самостоятельно следить за освобождением выделенной памяти.

Если этого не делать, возникает утечка памяти.

В современном C++ для многих задач вместо ручного управления памятью используются более высокоуровневые средства, например std::vector, std::array, std::unique_ptr и подобные механизмы.

Утечка памяти

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

int* getPointer() {
    int x = 2;
    return &x;
}

int main() {
    int* a = getPointer();
    std::cout << *a << std::endl;
    return 0;
}

Похожий пример с new иллюстрирует утечку памяти.

void f() {
    int* ptr = new int{ 10 };
}

int main() {
    f();
    return 0;
}

После выхода из f локальная переменная ptr перестаёт существовать.

Но выделенный через new объект в куче сам по себе не освобождается.

Адрес этого объекта был сохранён только в ptr, а после выхода из функции этот адрес потерян.

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

Это и есть утечка памяти - память выделена, но не освобождена.

Для того, чтобы исправить это - нужно использовать оператор delete.

void f() {
    int* ptr = new int{ 10 };
    std::cout << *ptr << std::endl;
    delete ptr;
}

int main() {
    f();
    return 0;
}

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

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

Для выделения динамического массива используется new[].

int main() {
    int* numbers = new int[5];
    *(numbers + 1) = 30;
    std::cout << numbers[1] << std::endl;
    return 0;
}
30

Здесь выделяется память в куче под пять элементов типа int.

После этого с массивом можно работать через индексы и указатель с разыменованием.

int main() {
    int size;
    std::cin >> size;
    int* numbers = new int[size];
    return 0;
}

Размер массива при этом может быть определён во время выполнения.

Это и является основным преимуществом динамического массива по сравнению со статическим.

Освобождение памяти

Как вы уже поняли - память, выделенную через new, необходимо освобождать с помощью delete.

Это для единичного объекта, а для массива, созданного через new[], используется delete[]:

int main() {
    int* numbers = new int[5];
    numbers[0] = 10;
    delete[] numbers;
    return 0;
}

Важно запомнить это соответствие, нельзя вместо delete[] использовать delete и наоборот.

int main() {
    int* numbers = new int[5];
    delete numbers;
}

Такой код приводит к неопределённому поведению.

Неудачное выделение

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

int main() {
    // Error (active)	E0095	array is too large
    int* numbers = new int[1'000'000'000'000]; 
    //Unhandled exception at 0x00007FF901F55369 in Sandbox.exe:
    //Microsoft C++ exception: std::bad_alloc at memory location 0x00000018E35CFDA0.
    return 0;
}

Несмотря на ошибку код скомпилируется и упадёт в рантайме с ошибкой std::bad_alloc.

Существует другой вариант new, который не выбрасывает исключение, а возвращает nullptr.

int main() {
    int* numbers = new (std::nothrow) int[1'000'000'000'000];
    if (numbers == nullptr) {
        std::cout << "Allocation failed" << std::endl;
        return 1;
    }
    delete[] numbers;
    return 0;
}
Allocation failed

В этом случае результат необходимо проверять самостоятельно, иначе разыменование nullptr приведёт к неопределённому поведению.

Кроме std::bad_alloc могут возникать и другие ошибки, например std::bad_array_new_length.

int main() {
    int size = -1;
    int* arr = new int[size];
    std::cout << arr << std::endl;
    //Unhandled exception at 0x00007FF901F55369 in Sandbox.exe:
    //Microsoft C++ exception: std::bad_array_new_length at memory location 0x0000003D4DFEF8F0.
}

Освобождение не равно возврату памяти ОС

Важно понимать, что освобождение памяти не обязательно означает мгновенное уменьшение объёма памяти, занимаемого процессом.

Когда локальная переменная выходит из области видимости, её место на стеке становится доступным для повторного использования.

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

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

Я регулярно публикую материал и разборы ситуаций для различных игр, движков, а так же подходов разработки включая Internal, External и DMA.

Эта публикация содержит только часть статьи.

Полные уроки по C++, реверс-инжинирингу и разработке софта вы можете найти тут: