Занурення у вказівники дало розуміння зв’язку між змінними, даними та пам’яттю. Отже, ми маємо уявлення про те, як виглядає пам’ять, але нам ще належить поговорити про те, як керувати даними та, відповідно, пам’яттю. Для короткочасних і простих сценаріїв це, ймовірно, не має значення. На 32-гігабайтному ноутбуці ви можете запустити свою програму, використати кілька сотень мегабайт оперативної пам’яті для читання файлу та аналізу відповіді HTTP, зробити щось дивовижне та вийти. Після виходу з програми ОС знає, що будь-яка пам’ять, яку вона надала вашій програмі, тепер може бути використана для чогось іншого.
Але для програм, які працюють днями, місяцями чи навіть роками, пам’ять стає обмеженим і дорогоцінним ресурсом, який, імовірно, потрібен іншим процесам, що виконуються на тій самій машині. Просто немає можливості чекати, поки програма завершиться, щоб звільнити пам’ять. Це основна робота збирача сміття: знати, які дані більше не використовуються, і звільняти їхню пам’ять. У Zig збирач сміття — це ви.
Більшість програм, які ви пишете, використовуватимуть три «області» пам’яті. Перша — це глобальний простір, де зберігаються константи програми, зокрема рядкові літерали. Усі глобальні дані вшиваються у двійковий файл, повністю відомі під час компіляції (а отже, і під час виконання) та незмінні. Ці дані існують протягом усього життя програми, ніколи не потребуючи більше чи менше пам’яті. Окрім впливу, який це має на розмір нашого двійкового файлу, нам взагалі не потрібно про це турбуватися.
Друга область пам’яті — це стек викликів, тема цієї частини. Третя область — купа, тема нашої наступної частини.
Реальної фізичної різниці між областями пам’яті немає, це концепція, створена ОС і виконуваним файлом.
Стекові кадри
Усі дані, які ми бачили досі, були або константами, що зберігалися в розділі глобальних даних нашого двійкового файлу, або локальними змінними. «Локальна» вказує на те, що змінна дійсна лише в межах області, де її оголошено. У Zig області видимості починаються і закінчуються фігурними дужками, { ... }. Більшість змінних обмежені функцією, включно з параметрами функції, або блоком потоку керування, як-от if. Але, як ми бачили, ви можете створювати довільні блоки і, таким чином, довільні області.
У попередній частині ми візуалізували пам’ять наших функцій main і levelUp, кожна з яких має User:
main: user -> ------------- (id: 1043368d0)
| 1 |
------------- (power: 1043368d8)
| 100 |
------------- (name.len: 1043368dc)
| 4 |
------------- (name.ptr: 1043368e4)
| 1182145c0 |-------------------------
levelUp: user -> ------------- (id: 1043368ec) |
| 1 | |
------------- (power: 1043368f4) |
| 100 | |
------------- (name.len: 1043368f8) |
| 4 | |
------------- (name.ptr: 104336900) |
| 1182145c0 |-------------------------
------------- |
|
............. пусте місце |
............. або інші дані |
|
------------- (1182145c0) <---
| 'G' |
-------------
| 'o' |
-------------
| 'k' |
-------------
| 'u' |
-------------
Є причина, чому levelUp стоїть одразу після main: це наш [спрощений] стек викликів. Коли наша програма запускається, main разом із локальними змінними поміщається в стек викликів. Коли викликається levelUp, його параметри та будь-які локальні змінні поміщаються в стек викликів. Важливо, що коли levelUp повертається, він знімається зі стеку. Після того, як levelUp повернеться, а керування знову опиниться в main, наш стек викликів виглядає так:
main: user -> ------------- (id: 1043368d0)
| 1 |
------------- (power: 1043368d8)
| 100 |
------------- (name.len: 1043368dc)
| 4 |
------------- (name.ptr: 1043368e4)
| 1182145c0 |-------------------------
-------------
|
............. пусте місце |
............. або інші дані |
|
------------- (1182145c0) <---
| 'G' |
-------------
| 'o' |
-------------
| 'k' |
-------------
| 'u' |
-------------
Під час виклику функції весь її стековий кадр поміщається в стек викликів. Це одна з причин, чому нам потрібно знати розмір кожного типу. Хоча ми можемо не знати довжину імені нашого користувача, доки не буде виконано цей конкретний рядок коду (якщо це не сталий рядковий літерал), ми знаємо, що наша функція має User і, на додаток до інших полів, нам знадобиться 8 байт для name.len і 8 байт для name.ptr.
Коли функція повертається, її стековий кадр, який було останнім додано до стеку викликів, знімається з нього. Щойно сталося щось дивовижне: пам’ять, яку використовує levelUp, автоматично звільнено! Хоча технічно ця пам’ять може бути повернута до ОС, наскільки я знаю, жодна реалізація фактично не зменшує стек викликів (хоча вони динамічно збільшуватимуть його, коли це необхідно). Проте пам’ять, яка використовувалася для зберігання кадру стеку levelUp, тепер вільна для використання в нашому процесі для іншого кадру стеку.
У звичайній програмі стек викликів може стати досить великим. З урахуванням усього коду та бібліотек, які використовує типова програма, ви отримуєте глибоко вкладені функції. Зазвичай це не проблема, але іноді ви можете зіткнутися з помилкою переповнення стеку. Це трапляється, коли в нашому стеку викликів закінчується місце. Найчастіше це трапляється з рекурсивними функціями — функціями, які викликають самі себе.
Як і нашими глобальними даними, стеком викликів керують ОС та виконуваний файл. Під час запуску програми та для кожного потоку, який ми запускаємо після цього, створюється стек викликів (розмір якого зазвичай можна налаштувати в ОС). Стек викликів існує протягом життя програми або, у разі потоку, протягом життя потоку. Після виходу з програми або потоку стек викликів звільняється. Але там, де наші глобальні дані містять усі глобальні дані програми, стек викликів містить лише кадри стеку для поточної ієрархії функцій. Це ефективно як з погляду використання пам’яті, так і з погляду простоти додавання та вилучення кадрів стеку.
«Висячі» вказівники (Dangling Pointers)
Стек викликів вражає як своєю простотою, так і ефективністю. Але це також лякає: коли функція повертається, будь-які її локальні дані стають недоступними. Це може здатися розумним, зрештою, це локальні дані, але це може викликати серйозні проблеми. Розглянемо цей код:
learning.zig
const std = @import("std");
pub fn main() void {
const user1 = User.init(1, 10);
const user2 = User.init(2, 20);
std.debug.print("Користувач {d} має силу {d}\n", .{user1.id, user1.power});
std.debug.print("Користувач {d} має силу {d}\n", .{user2.id, user2.power});
}
pub const User = struct {
id: u64,
power: i32,
fn init(id: u64, power: i32) *User {
var user = User{
.id = id,
.power = power,
};
return &user;
}
};
На перший погляд, було б розумно очікувати такого результату:
Користувач 1 має силу 10
Користувач 2 має силу 20
Я отримав
User 2 has power of 20
User 9114745905793990681 has power of 0
Ви можете отримати інші результати, але, судячи з моїх, user1 успадкував значення user2, а значення user2 є безглуздими. Основна проблема цього коду полягає в тому, що User.init повертає адресу локального користувача, &user. Це називається висячим вказівником — вказівником, який посилається на недійсну пам’ять. Це джерело багатьох помилок сегментації.
Коли кадр стеку знімається зі стеку викликів, будь-які посилання, які ми маємо на цю пам’ять, стають недійсними. Результат спроби доступу до цієї пам’яті не визначений. Імовірно, ви отримаєте безглузді дані або помилку сегментації. Ми могли б спробувати знайти якийсь сенс у моїх результатах, але це не та поведінка, на яку ми б хотіли або навіть могли покладатися.
Одна з проблем, пов’язаних із помилками цього типу, полягає в тому, що в мовах зі збиранням сміття наведений вище код є цілком прийнятним. Go, наприклад, виявить, що локальний user виходить за межі своєї області дії — функції init — і забезпечить його дійсність стільки, скільки це потрібно (те, як Go це робить, є деталлю реалізації, але має кілька варіантів, зокрема переміщення даних у купу, про що йдеться в наступній частині).
Інша проблема, на жаль, полягає в тому, що таку помилку буває важко помітити. У нашому прикладі вище ми явно повертаємо адресу локального користувача. Але така поведінка може ховатися всередині вкладених функцій і складних типів даних. Чи бачите ви можливі проблеми в такому неповному коді:
fn read() !void {
const input = try readUserInput();
return Parser.parse(input);
}
Усе, що повертає Parser.parse, живе довше за input. Якщо Parser містить посилання на input, це буде висячий вказівник, який просто чекає, щоб обвалити нашу програму. В ідеалі, якщо Parser потребує, щоб input існував стільки ж, скільки існує сам Parser, то він створить його копію, і ця копія буде прив’язана до його власного часу життя (докладніше про це в наступній частині). Але тут немає нічого, що забезпечило б виконання цього контракту. Документація Parser може пролити світло на те, що він очікує від input або що він з ним робить. За відсутності документації нам, можливо, доведеться копатися в коді, щоб це зрозуміти.
Простий спосіб вирішити нашу початкову помилку — змінити init так, щоб він повертав User, а не *User (вказівник на User). Тоді ми повернемо return user;, а не return &user;. Але це не завжди буде можливо. Дані часто виходять за жорсткі межі областей видимості функцій. Саме для цього у нас є третя область пам’яті, купа, тема наступної частини.
Перш ніж занурюватися в купу, знайте, що до кінця цього посібника ми побачимо ще один приклад висячих вказівників. На цьому етапі ми охопили достатньо мови, щоб надати трохи менш заплутаний приклад. Я хочу повернутися до цієї теми, тому що для розробників, які працюють із мовами зі збиранням сміття, це може викликати помилки та розчарування. Це те, з чим ви будете боротися. Все зводиться до того, щоб знати, де і коли існують дані.