Вивчаємо 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' не має члена з іменем '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. Але постійність 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-біт без знаку) ціле число). Але ніде в нашому коді ми не розрізали масив або навіть не мали масиву, вірно? Все, що ми зробили, це призначили «Goku» user.name. Як це спрацювало?

Рядкові літерали, які ви бачите у вихідному коді, мають відому довжину під час компіляції. Компілятор знає, що «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}.


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


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

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

Звичайно, у "реальній програмі" більшість рядків (і, загалом, масивів) невідомі під час компіляції. Класичним прикладом є введення даних від користувача, яке невідомо під час компіляції програми. Це те, до чого нам доведеться повернутися, коли ми будемо говорити про пам’ять. Але коротка відповідь полягає в тому, що для таких даних, які мають невідоме значення під час компіляції та, отже, невідому довжину, ми будемо динамічно розподіляти пам’ять під час виконання. Наші рядкові змінні, як і раніше типу []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 (він же інтерфейс{}). Навпаки, під час компіляції 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 не має перевантаження функцій і не має варіативних функцій (функцій із довільною кількістю аргументів). Але він має компілятор, здатний створювати спеціалізовані функції на основі переданих типів, у тому числі типів, виведених і створених самим компілятором.