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

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

Ось як виглядає код, написаний на Zig:

learning.zig

const std = @import("std");

// Цей код не буде скомпільовано, якщо `main` не `pub` (публічний)
pub fn main() void {
  const user = User{
    .power = 9001,
    .name = "Goku",
  };

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

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

Якщо ви збережете наведене вище як learning.zig і запустите zig run learning.zig, ви побачите: Користувач Goku має силу 9001.

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


Перегляньте розділ встановлюємо zig, щоб швидко розпочати роботу.


Імпорт

Дуже мало програм написано як один файл без стандартної бібліотеки чи зовнішніх бібліотек. Наша перша програма не виняток і використовує стандартну бібліотеку Zig для виведення результату. Система імпорту Zig є простою і спирається на функцію @import та ключове слово pub (щоб зробити код доступним поза поточним файлом).


Функції, які починаються з @, є вбудованими функціями. Вони надаються компілятором, на відміну від стандартної бібліотеки.


Ми імпортуємо модуль, вказавши його назву. Стандартна бібліотека Zig доступна під назвою «std». Щоб імпортувати певний файл, ми використовуємо його шлях відносно файлу, який виконує імпорт. Наприклад, якщо ми перемістимо структуру User у власний файл, скажімо models/user.zig:

models/user.zig

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

Потім ми імпортуємо його через:

learning.zig

const User = @import("models/user.zig").User;

Якщо наша структура User не була позначена як pub, ми отримали б таку помилку: ‘User’ is not marked ‘pub’.


models/user.zig може експортувати більше одного елемента. Наприклад, ми також можемо експортувати константу:

models/user.zig

pub const MAX_POWER = 100_000;

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

У цьому випадку ми могли б імпортувати обидва:

learning.zig

const user = @import("models/user.zig");
const User = user.User;
const MAX_POWER = user.MAX_POWER;

На цьому етапі у вас може виникнути більше питань, ніж відповідей. Що таке user у наведеному вище фрагменті? Ми цього ще не бачили, але що буде, якщо використати var замість const? Або, можливо, вам цікаво, як використовувати сторонні бібліотеки. Усе це хороші запитання, але щоб на них відповісти, нам спочатку потрібно більше дізнатися про Zig. Наразі нам доведеться задовольнитися тим, що ми навчилися: як імпортувати стандартну бібліотеку Zig, як імпортувати інші файли і як експортувати визначення.

Коментарі

Наступний рядок нашого прикладу Zig є коментарем:

// Цей код не буде скомпільовано, якщо `main` не `pub` (публічний)

Zig не має багаторядкових коментарів, як-от /* ... */ у C.

Існує експериментальна підтримка автоматичного створення документації на основі коментарів. Якщо ви бачили документацію стандартної бібліотеки Zig, то ви бачили це в дії. //! відомий як коментар верхнього рівня і може бути розміщений у верхній частині файлу. Коментар із потрійною скісною рискою (///), відомий як коментар документації, може розташовуватися в певних місцях, наприклад перед оголошенням. Ви отримаєте помилку компілятора, якщо спробуєте використати будь-який із цих типів документаційних коментарів не в тому місці.

Функції

Наш наступний рядок коду є початком нашої функції main:

pub fn main() void

Кожен виконуваний файл потребує функції під назвою main: це точка входу в програму. Якби ми перейменували main на щось інше, наприклад doIt, і спробували запустити zig run learning.zig, ми б отримали повідомлення про помилку, що ‘learning’ has no member named ‘main’.

Ігноруючи особливу роль main як точки входу в нашу програму, це справді проста функція: вона не приймає параметрів і нічого не повертає, тобто повертає void. Наступне трохи цікавіше:

learning.zig

const std = @import("std");

pub fn main() void {
  const sum = add(8999, 2);
  std.debug.print("8999 + 2 = {d}\n", .{sum});
}

fn add(a: i64, b: i64) i64 {
  return a + b;
}

Програмісти на C та C++ помітять, що Zig не потребує попередніх оголошень, тобто add викликається перед його визначенням.

Наступне, на що слід звернути увагу, це тип i64: 64-розрядне ціле число зі знаком. Деякі інші числові типи: u8, i8, u16, i16, u32, i32, u47, i47, u64, i64, f32 і f64. Включення u47 та i47 — не перевірка того, чи ви ще не спите; Zig підтримує цілі числа довільної розрядності. Хоч ви, імовірно, не використовуватимете їх часто, вони можуть стати в нагоді. Один із типів, який ви будете використовувати часто, це usize — беззнакове ціле розміру вказівника, що зазвичай представляє довжину/розмір чогось.


Крім f32 і f64, Zig також підтримує типи з плаваючою комою f16, f80 і f128.


Хоч для цього немає вагомих причин, якщо ми змінимо реалізацію add на:

fn add(a: i64, b: i64) i64 {
  a += b;
  return a;
}

Ми отримаємо помилку на a += b;: cannot assign to constant. Це важливий урок, до якого ми повернемося докладніше згодом: параметри функції є константами.

Заради кращої читабельності немає перевантаження функцій (тобто однієї й тієї самої функції з різними типами параметрів та/або їхньою кількістю). Наразі це все, що нам потрібно знати про функції.

Структури (struct)

Наступний рядок коду — це створення User, тип якого визначено в кінці нашого фрагмента коду. Визначення User таке:

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

Оскільки наша програма складається з одного файлу і тому User використовується лише у файлі, де його визначено, нам не потрібно робити його pub. Але тоді ми не змогли б експонувати оголошення іншим файлам.


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

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

Коли ми створюємо структуру, кожне поле має бути встановлено. Наприклад, у вихідному визначенні, де power не мав значення за замовчуванням, наведене нижче давало б помилку: missing struct field: power

const user = User{.name = "Goku"};

Однак з нашим значенням за замовчуванням наведене вище компілюється нормально.

Структури можуть мати методи, можуть містити оголошення (зокрема інших структур) і навіть можуть не містити жодних полів — у такому випадку вони діють радше як простір імен.

pub const User = struct {
  power: u64 = 0,
  name: []const u8,

  pub const SUPER_POWER = 9000;

  pub fn diagnose(user: User) void {
    if (user.power >= SUPER_POWER) {
      std.debug.print("це більше аніж {d}!!!", .{SUPER_POWER});
    }
  }
};

Методи — це звичайні функції, які можна викликати «через крапку». Обидві форми працюють:

// виклик функції diagnose для деякого екземпляра User
user.diagnose();

// Вище — це «синтаксичний цукор» над такою повною формою:
User.diagnose(user);

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


Оператор if — це перший потік керування, який ми побачили. Досить просто, правда? Ми розглянемо це докладніше в наступній частині.


diagnose визначено в нашому типі User і приймає User як свій перший параметр. Таким чином, ми можемо викликати його за допомогою крапкового синтаксису. Але функціям усередині структури необов’язково дотримуватися цього шаблону. Один із поширених прикладів — функції init для ініціалізації нашої структури:

pub const User = struct {
  power: u64 = 0,
  name: []const u8,

  pub fn init(name: []const u8, power: u64) User {
    return User{
      .name = name,
      .power = power,
    };
  }
}

Використання init — це просто домовленість, і в деяких випадках open або інша назва може мати більше сенсу. Якщо ви схожі на мене і не програмували на C++, синтаксис ініціалізації полів .$field = $value, може здатися трохи дивним, але ви швидко до нього звикнете.

Коли ми створювали «Goku», ми оголосили змінну user як const:

const user = User{
  .power = 9001,
  .name = "Goku",
};

Це означає, що ми не можемо модифікувати user. Щоб мати можливість модифікувати змінну, її потрібно оголосити за допомогою ключового слова var. Крім того, ви могли помітити, що тип user виводиться на основі того, що йому призначається. Ми могли б указати тип явно:

const user: User = User{
  .power = 9001,
  .name = "Goku",
};

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

const user: User = .{
  .power = 9001,
  .name = "Goku",
};

Однак таке використання досить незвичне. Єдине місце, де воно поширеніше, — це повернення структури з функції. Там тип можна вивести з типу, що повертається функцією. Найімовірніше, наша функція init буде написана так:

pub fn init(name: []const u8, power: u64) User {
  // замість return User{...}
  return .{
    .name = name,
    .power = power,
  };
}

Як і більшість речей, які ми розглядали досі, ми повернемося до структур пізніше, коли говоритимемо про інші частини мови. Але здебільшого вони прості.

Масиви та зрізи

Ми могли б оминути останній рядок нашого прикладу, але враховуючи, що наш невеликий фрагмент коду містить два рядки — «Goku» та «Користувач {s} має силу {d}\n», — вам, мабуть, цікаво, як влаштовані рядки в Zig. Щоб краще зрозуміти рядки, спершу дослідимо масиви та зрізи.

Масиви мають фіксований розмір із відомою під час компіляції довжиною. Довжина є частиною типу, тому масив із 4 цілих чисел зі знаком, [4]i32, — це інший тип, ніж масив із 5 цілих чисел зі знаком, [5]i32.

Довжину масиву можна визначити з ініціалізації. У наведеному нижче коді всі три змінні мають тип [5]i32:

const a = [5]i32{1, 2, 3, 4, 5};

// ми вже бачили цей синтаксис .{...} зі структурами
// він також працює з масивами
const b: [5]i32 = .{1, 2, 3, 4, 5};

// використовуйте _, щоб дозволити компілятору визначити довжину
const c = [_]i32{1, 2, 3, 4, 5};

Зріз, з іншого боку, — це вказівник на масив із довжиною. Довжина відома під час виконання. Пізніше ми розглянемо вказівники, але ви можете уявляти зріз як «вікно» в масив.


Якщо ви знайомі з Go, ви могли помітити, що зрізи в Zig дещо інші: вони не мають ємності, лише вказівник і довжину.


З огляду на наведене нижче,

const a = [_]i32{1, 2, 3, 4, 5};
const b = a[1..4];

мені б хотілося сказати вам, що b — це зріз із довжиною 3 та вказівником на a. Але оскільки ми «розрізали» наш масив за допомогою значень, відомих під час компіляції, як-от 1 і 4, наша довжина 3 теж відома під час компіляції. Zig усе це з’ясовує, тому b — це не зріз, а вказівник на масив цілих чисел довжиною 3. Зокрема, його тип — *const [3]i32. Отже, цю демонстрацію зрізу зірвала кмітливість Zig.

У реальному коді ви, швидше за все, використовуватимете зрізи частіше, ніж масиви. На добре це чи погано, але програми зазвичай мають більше інформації під час виконання, ніж під час компіляції. Однак у маленькому прикладі ми мусимо обдурити компілятор, щоб отримати те, що нам потрібно:

const a = [_]i32{1, 2, 3, 4, 5};
var end: usize = 3;
end += 1;
const b = a[1..end];

Тепер b — це справжній зріз, зокрема його тип []const i32. Ви бачите, що довжина зрізу не є частиною типу, оскільки вона відома лише під час виконання, а типи завжди повністю відомі під час компіляції. Під час створення зрізу ми можемо опустити верхню межу, щоб створити зріз до кінця того, що ми зрізаємо (масиву чи зрізу), наприклад: const c = b[2..];.


Якби ми зробили const end: usize = 4 без інкременту, тоді 1..end став би відомою під час компіляції довжиною для b і таким чином створив би вказівник на масив, а не на зріз. Я вважаю це трохи заплутаним, але це не те, що трапляється надто часто, і це не надто важко освоїти. Я б хотів пропустити це на цьому етапі, але не міг знайти чесного способу обійти цю деталь.


Вивчення Zig навчило мене, що типи дуже описові. Це не просто ціле число, чи булеве значення, чи навіть масив 32-розрядних цілих чисел зі знаком. Типи містять і іншу важливу інформацію. Ми говорили про те, що довжина є частиною типу масиву, а багато прикладів показали, що незмінність (const-ness) теж його частина. Наприклад, у нашому останньому прикладі тип b[]const i32. Ви можете переконатися в цьому за допомогою такого коду:

learning.zig

const std = @import("std");

pub fn main() void {
  const a = [_]i32{1, 2, 3, 4, 5};
  var end: usize = 4;
  end += 1;
  const b = a[1..end];
  std.debug.print("{any}", .{@TypeOf(b)});
}

Якщо ми спробуємо записати в b, наприклад b[2] = 5;, ми отримаємо помилку під час компіляції: cannot assign to constant. Це через тип b.

Щоб вирішити цю проблему, у вас може виникнути спокуса внести таку зміну:

// замінити const на var
var b = a[1..end];

але ви отримаєте ту саму помилку. Чому? Як підказку: який тип у b, або загальніше, що таке b? Зріз — це довжина і вказівник на [частину] масиву. Тип зрізу завжди походить від того, що він зрізає. Незалежно від того, чи b оголошено як const, він є зрізом [5]const i32, тому b має бути типу []const i32. Якщо ми хочемо мати можливість писати в b, нам потрібно змінити a з const на var.

learning.zig

const std = @import("std");

pub fn main() void {
  var a = [_]i32{1, 2, 3, 4, 5};
  var end: usize = 3;
  end += 1;
  const b = a[1..end];
  b[2] = 99;
}

Це працює, оскільки наш зріз тепер не []const i32, а []i32. Можливо, ви слушно дивуєтесь, чому це працює, коли b все ще є const. Але const-ність b стосується самого b, а не даних, на які вказує b. Що ж, я не певен, що це чудове пояснення, але мені цей код добре підкреслює різницю:

learning.zig

const std = @import("std");

pub fn main() void {
  var a = [_]i32{1, 2, 3, 4, 5};
  var end: usize = 3;
  end += 1;
  const b = a[1..end];
  b = b[1..];
}

Це не компілюється; як каже нам компілятор, ми cannot assign to constant. Але якби ми зробили var b = a[1..end];, тоді код працював би, оскільки сам b уже не є константою.

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

Рядки

Я б хотів сказати, що Zig має тип string і він чудовий. На жаль, це не так. У найпростішому вигляді рядки Zig — це послідовності (тобто масиви або зрізи) байтів (u8). Ми вже бачили це у визначенні поля name: name: []const u8,.

За угодою — і лише за угодою — такі рядки мають містити лише значення UTF-8, оскільки вихідний код Zig сам закодовано в UTF-8. Але це не нав’язується, і насправді немає різниці між []const u8, що представляє рядок ASCII або UTF-8, і []const u8, що представляє довільні двійкові дані. Та й не могло б бути різниці — це той самий тип.

З того, що ми дізналися про масиви та зрізи, ви маєте розуміти, що []const u8 — це зріз сталого масиву байтів (де байт — це 8-бітне беззнакове ціле число). Але ж ніде в нашому коді ми не зрізали масив і навіть не мали масиву, чи не так? Усе, що ми зробили, це призначили user.name значення «Goku». Як це спрацювало?

Рядкові літерали, які ви бачите у вихідному коді, мають відому під час компіляції довжину. Компілятор знає, що «Goku» має довжину 4. Тож ви були б близькі до правильної думки, що «Goku» найкраще представлено масивом — чимось на кшталт [4]const u8. Але рядкові літерали мають кілька особливих властивостей. Вони зберігаються в спеціальному місці двійкового файлу та дедуплікуються. Тому змінна рядкового літералу буде вказівником на це особливе розташування. Це означає, що тип «Goku» ближчий до *const [4]u8 — вказівника на сталий масив із 4 байтів.

Але це ще не все. Рядкові літерали завершуються нулем. Тобто вони завжди мають \0 у кінці. Рядки з нульовим завершенням важливі під час взаємодії з C. У пам’яті «Goku» насправді виглядатиме так: {'G', 'o', 'k', 'u', 0}, тож ви можете подумати, що тип — *const [5]u8. Але це було б у кращому разі неоднозначно, а в гіршому — небезпечно (ви могли б перезаписати нульовий термінатор). Натомість Zig має окремий синтаксис для представлення масивів із завершенням нулем. «Goku» має тип *const [4:0]u8 — вказівник на масив з 4 байтів із завершальним нулем. Хоч ми говоримо про рядки і зосереджуємося на масивах байтів із завершальним нулем (бо саме так рядки зазвичай представлені в C), синтаксис є загальнішим: [LENGTH:SENTINEL], де «SENTINEL» — це спеціальне значення, що знаходиться в кінці масиву. Тож, хоч я й не уявляю, навіщо вам це може знадобитися, наведене нижче цілком коректне:

learning.zig

const std = @import("std");

pub fn main() void {
  // масив із 3 булевих значень із false як sentinel-значенням
  const a = [3:false]bool{false, true, false};

  // цей рядок складніший, тож пояснення не буде
  std.debug.print("{any}\n", .{std.mem.asBytes(&a).*});
}

що виводить: { 0, 1, 0, 0}.


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


Імовірно, у вас ще лишаються сумніви. Якщо «Goku» — це *const [4:0]u8, чому ми змогли призначити його name, який має тип []const u8? Відповідь проста: Zig приводить (coerce) типи автоматично. Він робить це між кількома різними типами, але найочевидніше це з рядками. Це означає, що якщо функція має параметр []const u8 або структура має поле []const u8, то можна використовувати рядкові літерали. Оскільки рядки з нульовим завершенням є масивами, а масиви мають відому довжину, це приведення є дешевим — тобто воно не потребує проходження по всьому рядку для пошуку нульового термінатора.

Отже, говорячи про рядки, ми зазвичай маємо на увазі []const u8. За потреби ми явно вказуємо рядок із нульовим завершенням, який можна автоматично привести до []const u8. Але пам’ятайте, що []const u8 також використовується для представлення довільних двійкових даних, тому Zig не має поняття рядка, як у мовах програмування вищого рівня. Крім того, стандартна бібліотека Zig має лише дуже простий модуль Unicode.

Звісно, у «реальній програмі» більшість рядків (і взагалі масивів) не відомі під час компіляції. Класичний приклад — введення даних від користувача, яке не відоме під час компіляції програми. До цього нам доведеться повернутися, коли говоритимемо про пам’ять. Але коротка відповідь така: для даних, значення яких невідоме під час компіляції, а отже, і довжина невідома, ми динамічно розподілятимемо пам’ять під час виконання. Наші рядкові змінні, як і раніше типу []const u8, будуть зрізами, що вказуватимуть на цю динамічно виділену пам’ять.

comptime та anytype

У нашому останньому недослідженому рядку коду є набагато більше, ніж здається на перший погляд:

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

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

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

Можливо, вам цікаво, що в наведеному вище рядку вимагає виконання під час компіляції. Визначення функції print вимагає, щоб наш перший параметр — рядок формату — був відомий під час компіляції:

// зверніть увагу що "comptime" знаходиться перед параметром "fmt"
pub fn print(comptime fmt: []const u8, args: anytype) void {

І причина цього в тому, що print виконує додаткові перевірки під час компіляції, яких ви не отримаєте в більшості інших мов програмування. Що за перевірки? Скажімо, ви змінили формат на "це більше за {d}\n", але залишили два аргументи. Ви отримаєте помилку під час компіляції: unused argument in “це більше за {d}”. Вона також виконуватиме перевірки типів: змініть рядок формату на "{s} має силу {s}\n", і ви отримаєте invalid format string ‘s’ for type ‘u64’. Ці перевірки було б неможливо виконати під час компіляції, якби рядок формату не був відомий під час компіляції. Звідси й така вимога до параметра fmt.

Єдине місце, де comptime негайно вплине на ваше кодування, — це типи за замовчуванням для цілочисельних літералів і літералів з плаваючою комою, спеціальні comptime_int і comptime_float. Такий рядок коду недійсний: var i = 0;. Ви отримаєте помилку під час компіляції: variable of type ‘comptime_int’ must be const or comptime. Код comptime може працювати лише з даними, відомими під час компіляції, а для цілих чисел і чисел із плаваючою комою такі дані позначаються спеціальними типами comptime_int і comptime_float. Значення цього типу можна використовувати в коді, що виконується під час компіляції. Але ви, ймовірно, не витрачатимете більшу частину свого часу на написання коду, що виконується під час компіляції, тому це не особливо корисний тип за замовчуванням. Натомість потрібно надавати вашим змінним явний тип:

var i: usize = 0;
var j: f64 = 0;

Зауважте, що ця помилка виникла лише тому, що ми використали var. Якби ми використали const, помилки не було б, бо сама суть помилки в тому, що comptime_int має бути const.


У наступній частині ми докладніше розглянемо comptime під час вивчення узагальнених структур даних (generics).

Ще одна особливість нашого рядка коду — це дивний .{user.name, user.power}, який, виходячи з наведеного вище визначення print, передається як тип anytype. Цей тип не слід плутати з чимось на кшталт Object у Java чи any у Go (він же interface{}). Натомість під час компіляції Zig створить версію функції print спеціально для всіх типів, які їй передаються.

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

std.debug.print("{any}\n", .{@TypeOf(.{.year = 2023, .month = 8})});

який виведе:

struct{comptime year: comptime_int = 2023, comptime month: comptime_int = 8}

Тут ми вказали імена полів нашої анонімної структури — year і month. У нашому оригінальному коді ми цього не зробили. У такому випадку імена полів автоматично генеруються як «0», «1», «2» тощо. Хоч обидва є прикладами анонімного структурного літералу, той, що не містить імен полів, часто називають кортежем. Функція print очікує кортеж і використовує порядкову позицію у рядку формату, щоб отримати відповідний аргумент.

Zig не має ні перевантаження функцій, ні варіативних функцій (функцій із довільною кількістю аргументів). Але він має компілятор, здатний створювати спеціалізовані функції на основі переданих типів — зокрема й типів, виведених і створених самим компілятором.