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

Zig не має збирача сміття. Тягар керування пам’яттю лежить на вас, розробнику. Це велика відповідальність, оскільки вона безпосередньо впливає на продуктивність, стабільність і безпеку вашої програми.

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

learning.zig

const std = @import("std");

pub fn main() void {
  var user = User{
    .id = 1,
    .power = 100,
  };

  // цей рядок додано
  levelUp(user);
  std.debug.print("Користувач {d} має силу {d}\n", .{user.id, user.power});
}

fn levelUp(user: User) void {
  user.power += 1;
}

pub const User = struct {
  id: u64,
  power: i32,
};

Це був нечесний трюк; код не компілюється: local variable is never mutated. Це із-за посилання на змінну user у main. Змінна, яка ніколи не змінюється, має бути оголошена як const. Ви можете подумати: але в levelUp ми пере змінюємо user, що не так? Припустімо, що компілятор Zig помиляється, і ми обдуримо його. Ми змусимо компілятор бачити, що user змінено:

const std = @import("std");

pub fn main() void {
  var user = User{
    .id = 1,
    .power = 100,
  };
  user.power += 0;

  // решта коду така сама

Тепер ми отримуємо помилку в levelUp: cannot assign to constant. У частині 1 ми побачили, що параметри функції є константами, тому user.power += 1; недійсний. Щоб виправити помилку під час компіляції, ми можемо змінити функцію levelUp на:

fn levelUp(user: User) void {
  var u = user;
  u.power += 1;
}

Котрий буде успішно скомпільований, але в термінал виводиться наступне: Користувач 1 має силу 100, навіть незважаючи на те, що намір нашого коду явно полягає в тому, щоб levelUp збільшив потужність користувача до 101. Що відбувається?

Для розуміння корисно розглядати дані стосовно пам’яті, а змінні – як мітки, які пов’язують тип із певним місцем пам’яті. Наприклад, у main ми створюємо User. Проста візуалізація цих даних у пам’яті буде виглядати так:

user -> ------------ (id)
        |    1     |
        ------------ (power)
        |   100    |
        ------------

Слід зазначити дві важливі речі. По-перше, наша змінна user вказує на початок нашої структури. По-друге, поля розсташовуються послідовно. Пам’ятайте, що наш user також має тип. Цей тип повідомляє нам, що id є 64-бітним цілим числом, а power є 32-бітним цілим числом. Маючи посилання на початок наших даних і тип, компілятор може перекласти user.power на: access a 32 bit integer located 64 bits from the beginning. Це потужність змінних, вони посилаються на пам’ять і включають вводити інформацію, необхідну для осмисленого розуміння та маніпулювання пам’яттю.


За замовчуванням Zig не дає гарантій щодо розташування структур у пам’яті. Він може зберігати поля в алфавітному порядку, за зростанням розміру або з пропусками. Він може робити те, що хоче, якщо зможе правильно перекладати наш код. Ця свобода може дозволити певну оптимізацію. Лише якщо ми оголосимо упаковану структуру packed struct, ми отримаємо сильні гарантії щодо розташування пам’яті. Ми також можемо створити зовнішню структуру extern struct, яка гарантує, що макет пам’яті відповідатиме бінарному інтерфейсу програм C (ABI). Тим не менш, наша візуалізація user є розумною та корисною.


Ось трохи інша візуалізація, яка включає адреси пам’яті. Адреса пам’яті початку цих даних – це випадкова адреса, яку я придумав. Це адреса пам’яті, на яку посилається змінна user, яка також є значенням нашого першого поля, id. Однак, враховуючи цю початкову адресу, усі наступні адреси мають відому відносну адресу. Оскільки id є 64-бітним цілим числом, воно займає 8 байт пам’яті. Тому power має бути на $start_address + 8:

user ->   ------------  (id: 1043368d0)
          |    1     |
          ------------  (power: 1043368d8)
          |   100    |
          ------------

Щоб переконатися в цьому, я хотів би ввести адресу оператора: &. Як випливає з назви, оператор addressof повертає адресу змінної (він також може повертати адресу функції, чи не так?!). Зберігаючи існуюче визначення User, спробуйте це main:

pub fn main() void {
  const user = User{
    .id = 1,
    .power = 100,
  };
  std.debug.print("{*}\n{*}\n{*}\n", .{&user, &user.id, &user.power});
}

Цей код виводить в термінал адресу user, user.id і user.power. Ви можете отримати різні результати залежно від вашої платформи та інших факторів, але, сподіваюся, ви побачите, що адреса user і user.id однакові, тоді як user.power має зсув у 8 байт. Я отримав:

learning.User@1043368d0
u64@1043368d0
i32@1043368d8

Оператор addressof повертає покажчик на значення. Покажчик на значення є окремим типом. Адресою значення типу T є *T. Ми вимовляємо, що вказівник на T. Тому, якщо ми візьмемо адресу user, ми отримаємо *User або покажчик на User:

pub fn main() void {
  var user = User{
    .id = 1,
    .power = 100,
  };
  user.power += 0;

  const user_p = &user;
  std.debug.print("{any}\n", .{@TypeOf(user_p)});
}

Нашою початковою метою було збільшити power нашого користувача на 1 за допомогою функції levelUp. Ми отримали код для компіляції, але коли ми надрукували power, це все ще було початкове значення. Це трохи поспішно, але давайте змінимо код, щоб надрукувати адресу user у main і levelUp:

learning.zig

pub fn main() void {
  var user = User{
    .id = 1,
    .power = 100,
  };
  user.power += 0;

  // додали це
  std.debug.print("main: {*}\n", .{&user});

  levelUp(user);
  std.debug.print("Користувач {d} має силу {d}\n", .{user.id, user.power});
}

fn levelUp(user: User) void {
  // додали це
  std.debug.print("levelUp: {*}\n", .{&user});
  var u = user;
  u.power += 1;
}

Якщо ви запустите це, ви отримаєте дві різні адреси. Це означає, що user, який змінюється в levelUp, відрізняється від user в main. Це відбувається тому, що Zig передає копію значення. Це може здатися дивним спочатку, але одна з переваг полягає в тому, що той хто викликав функцію може бути впевнений, що функція не змінить параметр (тому що вона не може). У багатьох випадках це добре мати гарантію. Звичайно, іноді, як у випадку з levelUp, ми хочемо, щоб функція змінила параметр. Щоб досягти цього, нам потрібно, щоб levelUp діяв на фактичного user в main, а не на копію. Ми можемо зробити це, передавши адресу нашого користувача у функцію:

learning.zig

const std = @import("std");

pub fn main() void {
  var user = User{
    .id = 1,
    .power = 100,
  };

  // більше не потрібно
  // user.power += 1;

  // user -> &user
  levelUp(&user);
  std.debug.print("Користувач {d} має силу {d}\n", .{user.id, user.power});
}

// User -> *User
fn levelUp(user: *User) void {
  user.power += 1;
}

pub const User = struct {
  id: u64,
  power: i32,
};

Нам довелося внести дві зміни. Перший виклик levelUp з адресою користувача, тобто &user, замість user. Це означає, що наша функція більше не отримує User. Натомість він отримує *User, що було нашою другою зміною.

Нам більше не потрібен цей потворний прийом, коли користувач повинен змінюватися за допомогою user.power += 0;. Спочатку нам не вдалося отримати код для компіляції, оскільки user був var; компілятор сказав нам, що він ніколи не змінюється. Ми подумали, що, можливо, компілятор помилявся і «обдурили» ​​його, примусово зробивши мутацію. Але, як ми тепер знаємо, user, який мутував у levelUp, був іншим; компілятор мав рацію.

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

Методи

Швидше за все, ви б написали levelUp як метод структури User:

pub const User = struct {
  id: u64,
  power: i32,

  fn levelUp(user: *User) void {
    user.power += 1;
  }
};

Це викликає запитання: як ми викликаємо метод із приймачем покажчика? Можливо, нам потрібно зробити щось на зразок: &user.levelUp()? Насправді, ви просто викликаєте це як зазвичай, тобто user.levelUp(). Zig знає, що метод очікує вказівник і передає значення правильно (за посиланням).

Спочатку я вибрав функцію, а не метод, тому що вона явна і тому легше вчитися.

Немутабельні Параметри Функції

Я більш ніж впевнений, що за замовчуванням Zig передасть копію значення (“pass by value”). Незабаром ми побачимо, що реальність дещо витонченіша (підказка: як щодо складних значень із вкладеними об’єктами?)

Навіть дотримуючись простих типів, правда полягає в тому, що Zig може передавати параметри як завгодно, доки він може гарантувати збереження мети коду. У нашому оригінальному levelUp, де параметром був User, Zig міг передати копію користувача або посилання на main.user, якщо це могло гарантувати, що функція не змінить його. (Я знаю, що зрештою ми справді хотіли, щоб його змінили, але, зробивши тип User, ми сказали компілятору, що ми цього не хочемо).

Ця свобода дозволяє Zig використовувати найбільш оптимальну стратегію на основі типу параметра. Малі типи, такі як User, можна дешево передати за значенням (тобто скопіювати). Більші типи може бути дешевшим передати за посиланням. Zig може використовувати будь-який підхід, за умови збереження мети коду. Певною мірою це стало можливим завдяки наявності постійних параметрів функції.

Тепер ви знаєте, що одна з причин, чому параметри функції є константами.


Можливо, вам цікаво, як передача за посиланням може бути повільнішою, навіть порівняно з копіюванням справді невеликої структури. Далі ми побачимо це більш чітко, але суть полягає в тому, що виконання user.power, коли user є вказівником, додає трохи накладних витрат. Компілятор повинен зважити вартість копіювання та вартість доступу до полів опосередковано через вказівники.


Вказівник на Вказівник

Раніше ми розглядали, як виглядає пам’ять user у нашій функції main. Тепер, коли ми змінили levelUp, як буде виглядати його пам’ять?:

main:
user -> ------------  (id: 1043368d0)  <---
        |    1     |                      |
        ------------  (power: 1043368d8)  |
        |   100    |                      |
        ------------                      |
                                          |
        .............  пусте місце        |
        .............  або інші дані      |
                                          |
levelUp:                                  |
user -> -------------  (*User)            |
        | 1043368d0 |----------------------
        -------------

У межах levelUp user є вказівником на User. Його значенням є адреса. Звичайно, не будь-яка адреса, а адреса main.user. Варто чітко сказати, що змінна user у levelUp представляє конкретне значення. Це значення є адресою. І це не просто адреса, це також тип, *User. Це все дуже узгоджено, неважливо, говоримо ми про вказівники чи ні: змінні асоціюють інформацію про тип з адресою. Єдина особливість покажчиків полягає в тому, що, коли ми використовуємо синтаксис крапки, напр. user.power, Zig, знаючи, що user є вказівником, автоматично слідуватиме за адресою.


У деяких мовах для доступу до поля через вказівник потрібен інший символ.


Важливо розуміти, що сама змінна user у levelUp існує в пам’яті за певною адресою. Як і раніше, ми можемо побачити це на власні очі:

fn levelUp(user: *User) void {
  std.debug.print("{*}\n{*}\n", .{&user, user});
  user.power += 1;
}

Вище надруковано адресу, на яку посилається змінна user, а також її значення, яке є адресою user в main.

Якщо user є *User, то що таке &user? Це **User або вказівник на вказівник на User. Я можу робити це, доки в одного з нас не закінчиться пам’ять!

Існує деякі варіанти використання для кількох рівнів опосередкованості, але це не те, що нам зараз потрібно. Мета цього розділу — показати, що вказівники не є чимось особливим, це просто значення, яке є адресою та типом.

Вказівники в Структурах

Досі наш User був простим і містив два цілі числа. Його пам’ять легко візуалізувати, і коли ми говоримо про «копіювання», тут немає жодної двозначності. Але що відбувається, коли User стає складнішим і містить вказівник?

pub const User = struct {
  id: u64,
  power: i32,
  name: []const u8,
};

Ми додали name, яке є зрізом. Нагадаємо, що зріз — це довжина і покажчик. Якби ми ініціалізували нашого user іменем "Goku", як би це виглядало в пам’яті?

user -> -------------  (id: 1043368d0)
        |     1     |
        -------------  (power: 1043368d8)
        |    100    |
        -------------  (name.len: 1043368dc)
        |     4     |
        -------------  (name.ptr: 1043368e4)
  ------| 1182145c0 |
  |     -------------
  |
  |     .............  пусте місце
  |     .............  або інші дані
  |
  --->  -------------  (1182145c0)
        |    'G'    |
        -------------
        |    'o'    |
        -------------
        |    'k'    |
        -------------
        |    'u'    |
        -------------

Нове поле name — це зріз, який складається з полів len і ptr. Вони розташовуються послідовно разом з усіма іншими полями. На 64-розрядній платформі і len, і ptr матимуть 64 біти, або 8 байтів. Цікавою частиною є значення name.ptr: це адреса до іншого місця в пам’яті.


Оскільки ми використовували рядковий літерал, user.name.ptr вказуватиме на конкретне місце в області, де зберігаються всі константи в нашому двійковому файлі.


З глибоким вкладенням типи можуть стати набагато складнішими. Але прості чи складні, усі вони поводяться однаково. Зокрема, якщо ми повернемося до нашого вихідного коду, де levelUp взяв простий User, а Zig надав копію, як це виглядатиме тепер, коли у нас є вкладений вказівник?

Відповідь полягає в тому, що створюється лише поверхнева копія значення. Або, як кажуть деякі, копіюється лише пам’ять, яка безпосередньо адресується змінною. Може здатися, що levelUp отримає недороблену копію user, можливо, з недійсним name. Але пам’ятайте, що вказівник, як-от наше user.name.ptr, — це значення, а це значення — адреса. Копія адреси залишається тією самою адресою:

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'    |
                 -------------

З вищесказаного ми бачимо, що неглибоке копіювання буде працювати. Оскільки значення вказівника є адресою, копіювання значення означає, що ми отримуємо ту саму адресу. Це має важливі наслідки щодо мінливості. Наша функція не може змінювати поля, до яких безпосередньо звертається main.user, оскільки вона отримала копію, але вона має доступ до того самого name, тож чи може вона змінити це? У цьому конкретному випадку ні, name є const. Крім того, наше значення «Goku» — це рядковий літерал, який завжди є незмінним. Але, трохи попрацювавши, ми можемо побачити наслідки поверхневого копіювання:

learning.zig

const std = @import("std");

pub fn main() void {
  var name = [4]u8{'G', 'o', 'k', 'u'};
  const user = User{
    .id = 1,
    .power = 100,
    // розрізали це, [4]u8 -> []u8
    .name = name[0..],
  };
  levelUp(user);
  std.debug.print("{s}\n", .{user.name});
}

fn levelUp(user: User) void {
  user.name[2] = '!';
}

pub const User = struct {
  id: u64,
  power: i32,
  // []const u8 -> []u8
  name: []u8
};

Наведений вище код друкує “Go!u”. Нам довелося змінити тип name з []const u8 на []u8 і замість рядкового літералу, який завжди є незмінним, створити масив і розділити його. Дехто може побачити тут невідповідність. Передача за значенням запобігає зміні функції безпосередніх полів, але не полів зі значенням за вказівником. Якщо ми хотіли, щоб name було незмінним, ми повинні були оголосити його як []const u8 замість []u8.

Деякі мови програмування мають іншу реалізацію, але багато мов працюють саме так (або дуже близько). Незважаючи на те, що все це може здатися езотерикою, це фундаментальне значення для повсякденного програмування. Хороша новина полягає в тому, що ви можете освоїти це, використовуючи прості приклади та фрагменти; вона не стає складнішою, оскільки інші частини системи стають все більш складними.

Рекурсивні Структури

Іноді потрібна рекурсивна структура. Зберігаючи наш існуючий код, давайте додамо необов’язковий manager типу ?User до нашого User. Ми створимо двох User і призначимо одного менеджером іншому:

learning.zig

const std = @import("std");

pub fn main() void {
  const leto = User{
    .id = 1,
    .power = 9001,
    .manager = null,
  };

  const duncan = User{
    .id = 1,
    .power = 9001,
    .manager = leto,
  };

  std.debug.print("{any}\n{any}", .{leto, duncan});
}

pub const User = struct {
  id: u64,
  power: i32,
  manager: ?User,
};

Цей код не компілюється: struct ‘learning.User’ depends on itself. Не вдається, тому що кожен тип повинен мати відомий розмір під час компіляції.

Ми не зіткнулися з цією проблемою, коли додали name, хоча імена можуть мати різну довжину. Проблема не в розмірі значень, а в розмірі самих типів. Zig потребує цих знань, щоб робити все, про що ми говорили вище, наприклад, отримати доступ до поля на основі його положення зсуву. name був зрізом, []const u8, і він має відомий розмір: 16 байтів - 8 байтів для len і 8 байтів для ptr.

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

Ми бачили відповідь із name: використовувати вказівник. Покажчики завжди приймають байти usize. На 64-розрядній платформі це 8 байт. Подібно до того, як фактичне ім’я «Goku» не зберігалося разом із нашим user, використання вказівника означає, що наш менеджер більше не прив’язаний до розмітки пам’яті user.

learning.zig

const std = @import("std");

pub fn main() void {
  const leto = User{
    .id = 1,
    .power = 9001,
    .manager = null,
  };

  const duncan = User{
    .id = 1,
    .power = 9001,
    // змінили leto -> &leto
    .manager = &leto,
  };

  std.debug.print("{any}\n{any}", .{leto, duncan});
}

pub const User = struct {
  id: u64,
  power: i32,
  // змінили ?const User -> ?*const User
  manager: ?*const User,
};

Вам може ніколи не знадобитися рекурсивна структура, але це не про моделювання даних. Йдеться про розуміння вказівників і моделей пам’яті, а також про краще розуміння того, що збирає компілятор.


Багато розробників борються зі вказівниками, у них може бути щось невловиме. Вони не здаються конкретними, як ціле число, рядок або User. Нічого з цього не повинно бути кристально зрозумілим, щоб ви рухалися вперед. Але це варто освоїти, і не тільки для Zig. Ці деталі можуть бути приховані в таких мовах, як Ruby, Python і JavaScript, і меншою мірою в C#, Java і Go, але вони все ще присутні, впливаючи на те, як ви пишете код і як цей код виконується. Тож не поспішайте, пограйте з прикладами, додайте оператори друку, щоб переглянути змінні та їх адресу. Чим більше ви досліджуєте, тим ясніше стає.