C++

Динамічна пам'ять

Стек і Купа (Stack vs Heap). Динамічне виділення пам'яті в C++. Оператори new та delete. Висячі вказівники та витоки пам'яті. Динамічні масиви.

Дотепер ми працювали зі змінними, розмір яких компілятор знав наперед (під час компіляції). Наприклад, int a; завжди займає 4 байти, а int arr[10]; займає 40 байт. Але що робити, якщо ми не знаємо, скільки даних введе користувач?

На цьому уроці ми навчимося просити в операційної системи пам'ять "на льоту" під час роботи програми — це називається динамічним виділенням пам'яті.


Стек (Stack) vs Купа (Heap)

У C++ є три основні способи виділення пам'яті:

  1. Статичне виділення (для глобальних і статичних змінних) — пам'ять виділяється один раз при запуску програми і живе до її завершення.
  2. Автоматичне виділення (Стек) — для локальних змінних та параметрів функцій. Пам'ять автоматично виділяється при вході в блок {} і так само автоматично знищується при виході.
  3. Динамічне виділення (Купа) — пам'ять запитується вручну, програміст сам вирішує, коли її виділити і коли звільнити.

Стек (Stack) дуже швидкий, але жорстко обмежений у розмірах (зазвичай близько 1 Мегабайта). Якщо ви спробуєте створити занадто великий масив у стеку (int arr[1000000];), програма миттєво "впаде" з помилкою Stack Overflow (переповнення стеку). Крім того, розмір об'єктів у стеку має бути відомий наперед.

Купа (Heap) — це величезне сховище вільної пам'яті (гігабайти), яким керує операційна система (ОС). Оператори динамічної пам'яті працюють саме з Купою.


Оператор new

Щоб попросити в ОС пам'ять для однієї змінної під час роботи програми, використовується ключове слово new.

new int; // Робимо запит до ОС на 4 байти для типу int

Оператор new знаходить вільне місце в Купі, резервує його і повертає адресу цієї ділянки пам'яті. Щоб цю адресу не загубити, її обов'язково треба зберегти у вказівник! Ось тут вказівники розкривають свій головний потенціал:

int *ptr = new int; // Виділяємо пам'ять і зберігаємо її адресу у ptr
*ptr = 8;           // Записуємо туди значення 8

Також можна одразу ініціалізувати цю ділянку значенням:

int *ptr1 = new int(7);   // Пряма ініціалізація значенням 7
int *ptr2 = new int{8};   // Uniform-ініціалізація значенням 8 (стандарт C++11)

Що, якщо пам'яті не вистачить?

Сьогодні це рідкість, але якщо Купа буде повністю переповнена, ОС відмовить new у виділенні пам'яті. За замовчуванням програма згенерує виняток (exception) std::bad_alloc і впаде.

Якщо ви хочете м'яко обробити це (без падіння програми), використовують форму new (std::nothrow). Вона замість винятку просто повертає нульовий вказівник (nullptr):

#include <iostream>

using namespace std;

int main() {
    // Якщо пам'яті немає, value буде nullptr
    int *value = new (nothrow) int; 

    if (!value) {
        cout << "Could not allocate memory!\n";
        return 1; // Завершуємо з помилкою
    }
    
    // ... продовжуємо роботу ...
    return 0;
}

Оператор delete (Звільнення пам'яті)

Головна небезпека Купи: вона не автоматизована! ОС не знає, коли вам ця пам'ять уже не потрібна. Якщо ви виділили пам'ять через new, ви зобов'язані повернути її ОС, щойно закінчите з нею працювати.

Для цього використовується оператор delete.

#include <iostream>

using namespace std;

int main()
{
    int *ptr = new int(7);  // 1. Виділили пам'ять
    cout << *ptr << "\n";   // 2. Попрацювали з нею
    
    delete ptr;             // 3. ПОВЕРНУЛИ ОС!
    ptr = nullptr;          // 4. Очистили адресу, щоб уникнути помилок

    return 0;
}
Оператор deleteне знищує і не очищує сам вказівник ptr. Він іде за адресою, яку зберігає ptr, і повідомляє системі: "Ця ділянка відтепер вільна". Сам ptr продовжує існувати і зберігати стару адресу (яка йому більше не належить).

Висячі вказівники (Dangling Pointers)

Вказівник, який зберігає адресу пам'яті, що вже була звільнена (через delete), називається висячим вказівником.

Розіменування або повторне використання delete на висячому вказівнику — це найкоротший шлях до крашу (Undefined Behavior).

int *ptr = new int(8);
delete ptr; // Пам'ять звільнено, але ptr досі "дивиться" туди

// *ptr = 10;  // ❌ УЖАСНА ПОМИЛКА: запис у чужу пам'ять!
// delete ptr; // ❌ УЖАСНА ПОМИЛКА: повторне звільнення!

Золоте правило С++: Одразу після delete завжди присвоюйте вказівнику значення nullptr (якщо він не знищується негайно після цього). C++ гарантує, що delete nullptr; нічого не робить (це безпечно).

delete ptr;
ptr = nullptr; // Тепер він безпечний

Витік пам'яті (Memory Leak)

Уявіть ситуацію: ви виділили пам'ять у Купі, вказівник зберіг адресу, ви попрацювали... і забули викликати delete.

Після виходу з функції локальний вказівник (ptr) буде знищено (адже він живе у стеку). Але пам'ять у Купі, яку ви виділили, залишиться "висіти". Операційна система вважатиме, що ваша програма все ще її використовує. Оскільки вказівник знищений, адреса загублена назавжди. Ви фізично не зможете викликати delete для цієї ділянки.

Це явище називається витоком пам'яті (memory leak).

Кілька поширених випадків витоків:

// Випадок 1: Втрата адреси при виході з блоку
void doSomething() {
    int *ptr = new int(5);
    // Забули delete! При виході ptr знищиться, 4 байти загублено.
}

// Випадок 2: Переприсвоєння вказівника
int *p = new int(7);
p = new int(10);  // ❌ Стара адреса 7ки загублена, це витік!

// Випадок 3: Втрата через звичайну змінну
int val = 5;
int *p2 = new int(8);
p2 = &val;        // ❌ Динамічна пам'ять згубилася!

Якщо програма працює довго і постійно "губить" пам'ять, врешті-решт вона з'їсть усю RAM комп'ютера і ОС її примусово закриє.


Динамічні масиви

З однією змінною розібралися. А як бути з масивами, розмір яких невідомий? Наприклад, ми питаємо користувача: "Скільки студентів у групі?".

int size;
cin >> size;

// int arr[size]; // ❌ ПОМИЛКА компіляції: розмір має бути константою!

Для цього використовується оператор new[] (new для масивів). Він виділяє в Купі блок потрібного розміру і повертає адресу першого елемента:

int length;
cin >> length; // Може бути хоч 10, хоч 10 000

// Виділяємо масив з length елементів
int *array = new int[length]; 

array[0] = 5; // З масивом можна працювати через звичайні індекси!

Починаючи з C++11, динамічний масив можна ініціалізувати одразу, приєднавши {}, але його розмір також треба буде вказати у квадратних дужках явно:

int *arr1 = new int[5]();             // Усі 5 елементів будуть нулями
int *arr2 = new int[5]{ 1, 2, 3, 4, 5 }; // Uniform-ініціалізація

Видалення динамічного масиву (delete[])

Коли ви закінчили працювати з динамічним масивом, ви маєте використати оператор звільнення масивів delete[]. Квадратні дужки підказують системі, що за адресою лежить не одна змінна, а цілий ланцюжок.

#include <iostream>

using namespace std;

int main()
{
    int size = 100;
    int *arr = new int[size]; // 1. Виділили пам'ять під 100 чисел
    
    // ... робота з масивом ...

    delete[] arr;             // 2. ЗВІЛЬНИЛИ ВЕСЬ МАСИВ!
    arr = nullptr;            // 3. Обезопасили вказівник

    return 0;
}
Використання звичайного delete замість delete[] для масивів (вказівників на виділені масиви) призведе до дуже серйозних проблем. В кращому випадку звільниться лише перший елемент (витік пам'яті), в гіршому — програма впаде.

Підсумок

🏗️ Купа (Heap)

Купа — це велике сховище оперативної пам'яті, де ви можете виділяти та тримати гігабайти даних під час роботи програми "на льоту".

➕ Оператор new

int *p = new int; резервує ділянку в багато байтів і повертає адресу на неї. new int[N] виділяє динамічний масив.

🗑️ Оператор delete

delete p; або delete[] arr; обов'язково викликається для повернення пам'яті ОС. Після цього вказівник треба занулити (p = nullptr).

💧 Витоки та висячі

Втрата адреси без delete створює витік пам'яті (memory leak). Використання вказівника після delete (висючий вказівник) — крашить програму.
Copyright © 2026