Вивчаємо Zig Українською

Занурення у вказівники дало розуміння зв’язку між змінними, даними та пам’яттю. Отже, ми маємо уявлення про те, як виглядає пам’ять, але нам ще належить поговорити про те, як керувати даними та, відповідно, пам’яттю. Для короткочасних і простих сценаріїв це, ймовірно, не має значення. У 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 може пролити світло на те, що він очікує від input або що він з ним робить. За відсутності цього нам, можливо, доведеться копатися в коді, щоб зрозуміти це.


Простий спосіб вирішити нашу початкову помилку — змінити init так, щоб він повертав User, а не *User (вказівник на User). Тоді ми повернемо як return user;, а не return &user;. Але це не завжди буде можливо. Дані часто виходять за жорсткі межі функціональних областей. Для цього у нас є третя область пам’яті, купа, тема наступної частини.

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