Урок №89. Динамічне виділення пам’яті

  Юрій  | 

  Оновл. 12 Січ 2021  | 

 198

Мова С++ підтримує три основних типи виділення (або “розподілу”) пам’яті:

   Статичне виділення пам’яті виконується для статичних і глобальних змінних. Пам’ять виділяється один раз (при запуску програми) і зберігається протягом роботи всієї програми.

   Автоматичне виділення пам’яті виконується для параметрів функції і локальних змінних. Пам’ять виділяється при вході в блок, в якому знаходяться ці змінні, і видаляється при виході з нього.

   Динамічне виділення пам’яті є темою цього уроку.

Динамічне виділення змінних

Як статичне, так і автоматичне виділення пам’яті має дві загальні властивості:

   Розмір змінної/масиву повинен бути відомий під час компіляції.

   Виділення і звільнення пам’яті відбувається автоматично (коли змінна створюється/знищується).

У більшості випадків з цим все ОК. Однак, коли справа доходить до роботи з користувацьким вводом, то ці обмеження можуть призвести до проблем.

Наприклад, при використанні рядка для зберігання імені користувача, ми не знаємо наперед наскільки довгим воно буде, поки користувач його не введе. Або нам потрібно створити гру з непостійною кількістю монстрів (під час гри одні монстри вмирають, інші з’являються, намагаючись, таким чином, вбити гравця).

Якщо нам потрібно оголосити розмір всіх змінних під час компіляції, то найкраще, що ми можемо зробити — це спробувати вгадати їх максимальний розмір, сподіваючись, що цього буде достатньо:

Це погане рішення, принаймні, з трьох причин:

По-перше, втрачається пам’ять, якщо змінні фактично не використовуються або використовуються, але не всі. Наприклад, якщо ми виділимо 30 символів для кожного імені, але імена в середньому будуть займати по 15 символів, то споживання пам’яті буде в два рази більше, ніж нам насправді потрібно. Або розглянемо масив rendering: якщо він використовує тільки 20 000 полігонів, то пам’ять для інших 20 000 полігонів фактично витрачається даремно (тобто не використовується)!

По-друге, пам’ять для більшості звичайних змінних (включаючи фіксовані масиви) виділяється зі спеціального резервуара пам’яті — стеку. Обсяг пам’яті стеку в програмі, як правило, невеликий: в Visual Studio він за замовчуванням становить 1МБ. Якщо ви перевищите це значення, то відбудеться переповнення стеку, і операційна система автоматично завершить виконання вашої програми.

У Visual Studio це можна перевірити, запустивши наступний фрагмент коду:

Ліміт в 1МБ пам’яті може бути проблематичним для багатьох програм, особливо для тих, де використовується графіка.

По-третє, і найголовніше, це може призвести до штучних обмежень і/або переповнення масиву. Що станеться, якщо користувач спробує прочитати 500 записів з диску, але ми виділили пам’ять максимум для 400? Або ми виведемо користувачеві помилку, що максимальна кількість записів становить 400, або (в гіршому випадку) виконається переповнення масиву і потім щось дуже нехороше.

На щастя, ці проблеми легко усуваються за допомогою динамічного виділення пам’яті. Динамічне виділення пам’яті — це спосіб запиту пам’яті з операційної системи запущеними програмами в міру необхідності. Ця пам’ять не виділяється з обмеженої пам’яті стеку програми, а виділяється з набагато більшого сховища, керованого операційною системою — купи. На сучасних комп’ютерах розмір купи може становити гігабайти пам’яті.

Для динамічного виділення пам’яті для змінної використовується оператор new:

У прикладі, наведеному вище, ми робимо запит на виділення пам’яті з операційної системи для цілочисельної змінної. Оператор new повертає вказівник, що містить адресу виділеної пам’яті.

Для доступу до виділеної пам’яті створюємо вказівник:

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

Ось один з випадків, коли вказівники корисні. Без вказівника з адресою на тільки що виділену пам’ять у нас не було б способу отримати доступ до неї.

Як працює динамічне виділення пам’яті?

На вашому комп’ютері є пам’ять (можливо, більша її частина), яка доступна для використання програмами. При запуску вашої програми операційна система завантажує її в деяку частину цієї пам’яті. І ця пам’ять, яка використовується вашою програмою, розділена на кілька частин, кожна з яких виконує певне завдання. Одна частина містить ваш код, інша використовується для виконання звичайних операцій (відстеження функцій, створення і знищення глобальних і локальних змінних і т.д.). Ми поговоримо про це трохи пізніше. Тим не менш, велика частина доступної пам’яті комп’ютера просто знаходиться в очікуванні запитів на виділення від програм.

Коли ви динамічно виділяєте пам’ять, то ви просите операційну систему зарезервувати частину цієї пам’яті для використання вашою програмою. Якщо ОС може виконати цей запит, то повертається адреса цієї пам’яті назад в вашу програму. З цього моменту і надалі ваша програма зможе використовувати цю пам’ять, як тільки побажає. Коли ви вже виконали з цією пам’яттю все, що було необхідно, то її потрібно повернути назад в операційну систему для розподілу між іншими запитами на виділення.

На відміну від статичного або автоматичного виділення пам’яті, програма самостійно відповідає за запит і зворотнє повернення динамічно виділеної пам’яті.

Звільнення пам’яті

Коли ви динамічно виділяєте змінну, то ви також можете її ініціалізувати за допомогою прямої ініціалізації або uniform-ініціалізації:

Коли вже все, що було потрібно, виконано з динамічно виділеною змінної — потрібно явно вказати для С++ звільнити цю пам’ять. Для змінних це виконується за допомогою оператора delete:

Оператор delete насправді нічого не видаляє. Він просто повертає пам’ять, яка була виділена раніше, назад в операційну систему. Потім операційна система може перепризначити цю пам’ять іншому додатку/програмі (або цій же програмі знову).

Хоча може здатися, що ми видаляємо змінну, але це не так! Змінна-вказівник має ту ж область видимості, що і раніше, і їй можна присвоїти нове значення, як і будь-якій іншій змінній.

Зверніть увагу, видалення вказівника, який не вказує на динамічно виділену пам’ять, може призвести до проблем.

“Висячі” вказівники

Мова C++ не надає жодних гарантій щодо того, що станеться з вмістом звільненої пам’яті або зі значенням вказівника, який ви видалили. У більшості випадків, пам’ять, яка повертається назад операційній системі, буде містити ті ж значення, які були у неї до звільнення, а вказівник так і залишиться вказувати на тільки що звільнену (видалену) пам’ять.

Вказівник, який вказує на звільнену пам’ять, називається “висячим” вказівником. Розіменування або видалення висячого вказівника призведе до несподіваних результатів. Розглянемо наступну програму:

У вищенаведеній програмі значення 8, яке раніше було присвоєно динамічній змінній, після звільнення може і далі перебувати там, а може і ні. Також можливо, що звільнена пам’ять вже могла бути виділена іншому додатку/програмі (або залишена для власного використання операційної системи), і спроба доступу до неї призведе до того, що операційна система автоматично припинить виконання вашої програми.

Процес звільнення пам’яті може також призвести і до створення кількох “висячих” вказівників. Розглянемо наступний приклад:

Є кілька рекомендацій, які можуть тут допомогти:

   По-перше, намагайтеся уникати ситуацій, коли кілька вказівників вказують на одну і ту ж частину виділеної пам’яті. Якщо це неможливо, то з’ясуйте, який з усіх вказівників «володіє» пам’яттю (і відповідає за її видалення), а які вказівники просто отримують до неї доступ.

   По-друге, коли ви видаляєте вказівник, і, якщо він не виходить з області видимості відразу ж після видалення, його потрібно зробити нульовим (тобто присвоїти йому значення 0 або nullptr). Під “виходом з області видимості відразу ж після видалення” мається на увазі, що ви видаляєте вказівник в самому кінці блоку, в якому він оголошений.

Правило: Присвоюйте видаленим вказівникам значення 0 (або nullptr), якщо вони не виходять з області видимості відразу ж після видалення.

Оператор new

При запиті пам’яті з операційної системи в рідкісних випадках вона може бути не виділена (тобто її може і не бути в наявності).

За замовчуванням, якщо оператор new не спрацював, пам’ять не виділилася, то генерується виняток bad_alloc. Якщо цей виняток буде неправильно опрацьований (а саме так і буде, оскільки ми ще не розглядали винятки і їх обробку), то програма просто припинить своє виконання (станеться збій) з помилкою необробленого винятку.

У багатьох випадках процес генерації винятку оператором new (як і збій програми) — небажаний, тому є альтернативна форма оператора new, яка повертає нульовий вказівник, якщо пам’ять не може бути виділена. Потрібно просто додати константу std::nothrow між ключовим словом new і типом даних:

У вищенаведеному прикладі, якщо оператор new не поверне вказівник з динамічно виділеною пам’яттю, то повернеться нульовий вказівник.

Розіменовувати його також не рекомендується, так як це призведе до несподіваних результатів (швидше за все, до збою в програмі). Тому найкращою практикою є перевірка всіх запитів на виділення пам’яті для забезпечення того, що ці запити будуть виконані успішно та пам’ять виділиться:

Оскільки не виділення пам’яті оператором new відбувається вкрай рідко, то зазвичай програмісти забувають виконувати цю перевірку!

Нульові вказівники і динамічне виділення пам’яті

Нульові вказівники (вказівники зі значенням 0 або nullptr) особливо корисні в процесі динамічного виділення пам’яті. Їх наявність як би повідомляє нам: «Цьому вказівнику не виділено ніякої пам’яті». А це, в свою чергу, можна використовувати для виконання умовного виділення пам’яті:

Видалення нульового вказівника ні на що не впливає. Таким чином, в наступному немає необхідності:

Замість цього ви можете просто написати:

Якщо ptr не є нульовим, то динамічно виділена змінна буде видалена. Якщо значенням вказівника є нуль, то нічого не станеться.

Витік пам’яті

Динамічно виділена пам’ять не має області видимості, тобто вона залишається виділеною до тих пір, поки не буде явно звільнена або поки ваша програма не завершить своє виконання (і операційна система очистить всі буфери пам’яті самостійно). Однак вказівники, які використовуються для зберігання динамічно виділених адрес в пам’яті, дотримуються правил області видимості звичайних змінних. Ця невідповідність може викликати цікаву поведінку, наприклад:

Тут ми динамічно виділяємо цілочисельну змінну, але ніколи не звільняємо пам’ять через використання оператора delete. Оскільки вказівники слідують всім тим же правилам, що і звичайні змінні, то коли функція завершить своє виконання, ptr вийде з області видимості. Оскільки ptr — це єдина змінна, що зберігає адресу динамічно виділеної цілочисельної змінної, то коли ptr знищиться, більше не залишиться вказівників на динамічно виділену пам’ять. Це означає, що програма «втратить» адресу динамічно виділеної пам’яті. І в результаті цю динамічно виділену цілочисельну змінну не можна буде видалити.

Це називається витоком пам’яті. Витік пам’яті відбувається, коли ваша програма втрачає адресу певної динамічно виділеної частини пам’яті (наприклад, змінної або масиву), перш ніж повернути її назад в операційну систему. Коли це відбувається, то програма вже не може видалити цю динамічно виділену пам’ять, оскільки вона більше не знає, де та знаходиться. Операційна система також не може використовувати цю пам’ять, оскільки вважається, що вона як і раніше використовується вашою програмою.

Витоки пам’яті “з’їдають” вільну пам’ять під час виконання програми, зменшуючи кількість доступної пам’яті не тільки для цієї програми, але і для інших програм також. Програми з серйозними проблемами з витоком пам’яті можуть “з’їсти” всю доступну пам’ять, в результаті чого ваш комп’ютер буде повільніше працювати або навіть станеться збій. Тільки після того, як виконання вашої програми завершиться, операційна система зможе очистити і повернути всю пам’ять, яка “втекла”.

Хоча витік пам’яті може виникнути і через те, що вказівник виходить з області видимості, можливі й інші способи, які можуть призвести до витоку пам’яті. Наприклад, якщо вказівнику, який зберігає адресу динамічно виділеної пам’яті, присвоїти інше значення:

Це легко вирішується видаленням вказівника перед операцією переприсвоювання:

Крім того, витік пам’яті також може статися і через подвійне виділення пам’яті:

Адреса, що повертається з другого виділення пам’яті, перезаписує адресу з першого виділення. Відповідно, перше динамічне виділення стає витоком пам’яті!

Точно так же цього можна уникнути видаленням вказівника перед операцією переприсвоювання.

Висновки

За допомогою операторів new і delete можна динамічно виділяти окремі змінні в програмі. Динамічно виділена пам’ять не має області видимості і залишається виділеною до тих пір, поки не відбудеться її звільнення або поки програма не завершить своє виконання. Будьте обережні, не розіменовуйте “висячі” або нульові вказівники.

Оцінити статтю:

1 Зірка2 Зірки3 Зірки4 Зірки5 Зірок (4 оцінок, середня: 5,00 з 5)
Loading...

Залишити відповідь

Ваш E-mail не буде опублікований. Обов'язкові поля відмічені *