Вивчаємо 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 до іншого і не буде відоме під час компіляції.

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