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

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

Знову "Висячі" вказівники

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

Чи можете ви зрозуміти, що це за наступні результати?

const std = @import("std");

pub fn main() !void {
 var gpa = std.heap.GeneralPurposeAllocator(.{}){};
 const allocator = gpa.allocator();

 var lookup = std.StringHashMap(User).init(allocator);
 defer lookup.deinit();

 const goku = User{.power = 9001};

 try lookup.put("Goku", goku);

 // повертаємо опціональне значення, .? запанікує якщо "Goku"
 // немає в хещ-мапі
 const entry = lookup.getPtr("Goku").?;

 std.debug.print("Goku's power is: {d}\n", .{entry.power});

 // повертає true/false в залежності від того чи був елемент видалений
 _ = lookup.remove("Goku");

 std.debug.print("Сила Goku становить: {d}\n", .{entry.power});
}

const User = struct {
 power: i32,
};

Коли я це запустив, я отримав

Сила Goku становить: 9001
Сила Goku становить: -1431655766

Цей код представляє загальний std.StringHashMap Zig, який є спеціалізованою версією std.AutoHashMap з типом ключа, встановленим як []const u8. Навіть якщо ви не на 100% впевнені, що відбувається, можна припустити, що мій висновок стосується того факту, що наш другий print відбувається після того, як ми remove запис із lookup. Закоментуйте виклик remove, і результат буде нормальним.

Ключ до розуміння наведеного вище коду полягає в тому, щоб знати, де існують дані/пам’ять, або, іншими словами, хто ними _володіє. Пам’ятайте, що аргументи Zig передаються за значенням, тобто ми передаємо [не глибоку] копію значення. «Користувач» у нашому «пошуку» не є тією самою пам’яттю, на яку посилається «goku». Наш наведений вище код має двох користувачів, кожен зі своїм власником. goku належить main, а його копія належить lookup.

Метод getPtr повертає вказівник на значення в карті, у нашому випадку він повертає *User. Ось у чому полягає проблема, remove робить наш вказівник entry недійсним. У цьому прикладі близькість getPtr і remove робить проблему дещо очевидною. Але неважко уявити код, що викликає remove, не знаючи, що посилання на запис міститься десь в іншому місці.


Коли я писав цей приклад, я не був упевнений, що станеться. Можна було реалізувати remove шляхом встановлення внутрішнього прапора, відкладаючи фактичне видалення до наступної події. Якби це було так, вищесказане могло б «спрацювати» в наших простих випадках, але не вдалося б із більш складним використанням. Це звучить жахливо важко налагодити.


Крім того, що ми не викликаємо remove, ми можемо виправити це кількома різними способами. По-перше, ми можемо використовувати get замість getPtr. Це повертатиме User замість *User і таким чином повертатиме копію значення в lookup. Тоді у нас буде три User.

  1. Наш оригінальний goku, прив'язаний до функції.
  2. Копія в lookup, яка належить пошуку.
  3. І копія нашої копії, entry, також прив’язана до функції.

Оскільки entry тепер буде власною незалежною копією користувача, видалення його з lookup не призведе до недійсності.

Інший варіант – змінити тип пошуку з StringHashMap(User) на StringHashMap(*const User). Цей код працює:

learning.zig

const std = @import("std");

pub fn main() !void {
  var gpa = std.heap.GeneralPurposeAllocator(.{}){};
  const allocator = gpa.allocator();

  // User -> *const User
  var lookup = std.StringHashMap(*const User).init(allocator);
  defer lookup.deinit();

  const goku = User{.power = 9001};

  // goku -> &goku
  try lookup.put("Goku", &goku);

  // getPtr -> get
  const entry = lookup.get("Goku").?;

  std.debug.print("Сила Goku становить: {d}\n", .{entry.power});
  _ = lookup.remove("Goku");
  std.debug.print("Сила Goku становить: {d}\n", .{entry.power});
}

const User = struct {
  power: i32,
};

У наведеному вище коді є ряд тонкощів. По-перше, тепер у нас є єдиний User, goku. Значення в lookup і entry є посиланнями на goku. Наш виклик remove все одно видаляє значення з нашого lookup, але це значення є просто адресою user, це не є user сам по собі. Якби ми зупинилися на getPtr, ми отримали б недійсний **User, недійсний через remove. В обох рішеннях ми мали використовувати get замість getPtr, але в цьому випадку ми просто копіюємо адресу, а не повний User. Для великих об’єктів це може бути значною різницею.

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

Володіння

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

Цей код заповнює наш lookup іменами, які ви вводите в терміналі. Порожнє ім'я зупиняє цикл підказки. Нарешті, він визначає, чи було Leto одним із наданих імен.

learning.zig

const std = @import("std");
const builtin = @import("builtin");

pub fn main() !void {
  var gpa = std.heap.GeneralPurposeAllocator(.{}){};
  const allocator = gpa.allocator();

  var lookup = std.StringHashMap(User).init(allocator);
  defer lookup.deinit();

  // stdin це std.io.Reader
  // і протидежний std.io.Writer, який ми вже бачили
  const stdin = std.io.getStdIn().reader();

  // stdout це std.io.Writer
  const stdout = std.io.getStdOut().writer();

  var i: i32 = 0;
  while (true) : (i += 1) {
    var buf: [30]u8 = undefined;
    try stdout.print("Будь ласка, введіть імʼя: ", .{});
    if (try stdin.readUntilDelimiterOrEof(&buf, '\n')) |line| {
      var name = line;
      if (builtin.os.tag == .windows) {
        // У Windows рядки закінчуються на\r\n.
        // Нам потрібно прибрати \r
        name = @constCast(std.mem.trimRight(u8, name, "\r"));
      }
      if (name.len == 0) {
        break;
      }
      try lookup.put(name, .{.power = i});
    }
  }

  const has_leto = lookup.contains("Leto");
  std.debug.print("{any}\n", .{has_leto});

}

const User = struct {
  power: i32,
};

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


Код чутливий до регістру, але незалежно від того, наскільки ідеально ми вводимо "Leto", contains завжди повертає false. Давайте виправимо це, повторивши lookup і викинувши ключі та значення:

// Розмістіть цей код після циклу while

var it = lookup.iterator();
while (it.next()) |kv| {
  std.debug.print("{s} == {any}\n", .{kv.key_ptr._, kv.value_ptr._});
}

Цей шаблон ітератора поширений у Zig і ґрунтується на синергії між типами while і необов’язковими. Наш елемент ітератора повертає вказівники на наш ключ і значення, тому ми розіменовуємо їх за допомогою .*, щоб отримати доступ до фактичного значення, а не до адреси. Результат залежатиме від того, що ви введете, але я отримав:

Будь ласка, введіть імʼя: Paul
Будь ласка, введіть імʼя: Teg
Будь ласка, введіть імʼя: Leto
Будь ласка, введіть імʼя:

�� == learning.User{ .power = 1 }

��� == learning.User{ .power = 0 }

��� == learning.User{ .power = 2 }
false

Значення виглядають добре, але не ключі. Якщо ви не впевнені, що відбувається, можливо, це моя вина. Раніше я навмисно перевернув вашу увагу. Я сказав, що хеш-карти часто довгоживуть і тому вимагають довгоживучих значень. Правда полягає в тому, що вони вимагають довготривалих значень а також довгоживучих ключів! Зверніть увагу, що buf визначено всередині нашого циклу while. Коли ми викликаємо put, ми надаємо нашій хеш-карті ключ, який має набагато коротший час життя, ніж сама хеш-карта. Переміщення buf поза циклу while вирішує нашу проблему тривалості життя, але цей буфер повторно використовується в кожній ітерації. Це все одно не працюватиме, оскільки ми змінюємо базові ключові дані.

Для нашого вищенаведеного коду насправді є лише одне рішення: наш пошук має взяти на себе право власності на ключі. Нам потрібно додати один рядок і змінити інший:

// замініть існуючий lookup.put цими двома рядками
const owned_name = try allocator.dupe(u8, name);

// name -> owned_name
try lookup.put(owned_name, .{.power = i});

dupe — це метод std.mem.Allocator, якого ми раніше не бачили. Він виділяє дублікат заданого значення. Код тепер працює, тому що наші ключі, які тепер знаходяться в купі, переживають lookup. Насправді ми надто добре подовжили термін служби цих рядків: ми запровадили витоки пам’яті.

Ви могли подумати, що коли ми викликаємо lookup.deinit, наші ключі та значення будуть звільнені для нас. Але немає універсального рішення, яке б міг використовувати StringHashMap. По-перше, ключі можуть бути рядковими літералами, які не можна звільнити. По-друге, вони могли бути створені з іншим розподільником. Нарешті, хоча й більш просунутий, існують законні випадки, коли ключі можуть не належати хеш-карті.

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

// замініть існуючий
// defer lookup.deinit();
// цим:
defer {
  var it = lookup.keyIterator();
  while (it.next()) |key| {
    allocator.free(key.*);
  }
  lookup.deinit();
}

Наша логіка defer, перша, яку ми бачили з блоком, звільняє кожен ключ, а потім деініціалізує lookup. Ми використовуємо keyIterator лише для ітерації ключів. Значення ітератора є покажчиком на ключовий запис у хеш-карті, _[]const u8. Ми хочемо звільнити фактичне значення, оскільки це те, що ми виділили за допомогою dupe, тому ми розіменовуємо значення за допомогою ._.

Обіцяю, ми закінчили говорити про висячі покажчики та керування пам’яттю. Те, що ми обговорювали, може бути незрозумілим або надто абстрактним. Добре повернутися до цього, коли у вас є більш практична проблема для вирішення. Тим не менш, якщо ви плануєте написати щось нетривіальне, це те, що вам майже напевно доведеться освоїти. Коли ви почуваєтесь готові до цього, я закликаю вас взяти приклад циклу підказок і пограти з ним самостійно. Представте тип UserLookup, який інкапсулює все керування пам’яттю, яке нам потрібно було зробити. Спробуйте використовувати значення *User замість User, створюючи користувачів у купі та звільняючи їх, як ми робили ключі. Напишіть тести, які охоплюють вашу нову структуру, використовуючи std.testing.allocator, щоб переконатися, що ви не втрачаєте пам’ять.

Динамічний масив ArrayList

Ви будете раді знати, що можете забути про наш IntList і загальну альтернативу, яку ми створили. Zig має правильну реалізацію динамічного масиву: std.ArrayList(T).

Це досить стандартна річ, але це настільки поширена і використовувана структура даних, що варто побачити її в дії:

learning.zig

const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;

pub fn main() !void {
  var gpa = std.heap.GeneralPurposeAllocator(.{}){};
  const allocator = gpa.allocator();

  var arr = std.ArrayList(User).init(allocator);
  defer {
    for (arr.items) |user| {
      user.deinit(allocator);
    }
    arr.deinit();
  }

  const stdin = std.io.getStdIn().reader();

  const stdout = std.io.getStdOut().writer();

  var i: i32 = 0;
  while (true) : (i += 1) {
    var buf: [30]u8 = undefined;
    try stdout.print("Будь ласка, введіть імʼя: ", .{});
    if (try stdin.readUntilDelimiterOrEof(&buf, '\n')) |line| {
    var name = line;
    if (builtin.os.tag == .windows) {
      name = @constCast(std.mem.trimRight(u8, name, "\r"));
    }
    if (name.len == 0) {
      break;
    }
    const owned_name = try allocator.dupe(u8, name);
    try arr.append(.{.name = owned_name, .power = i});
    }
  }

  var has_leto = false;
  for (arr.items) |user| {
    if (std.mem.eql(u8, "Leto", user.name)) {
      has_leto = true;
      break;
    }
  }

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

}

const User = struct {
  name: []const u8,
  power: i32,

  fn deinit(self: User, allocator: Allocator) void {
    allocator.free(self.name);
  }
};

Вище наведено відтворення нашого коду хеш-карти, але з використанням ArrayList(User). Застосовуються ті самі правила керування терміном служби та пам’яттю. Зауважте, що ми все ще створюємо дуп імені, і ми все ще звільняємо кожне ім’я перед тим, як деінітувати ArrayList.

Це гарний момент, щоб зазначити, що Zig не має властивостей або приватних полів. Ви можете побачити це, коли ми отримуємо доступ до arr.items, щоб перебирати значення. Причиною відсутності властивостей є усунення джерела сюрпризів. У Zig, якщо це виглядає як доступ до поля, це є доступ до поля. Особисто я вважаю, що відсутність приватних полів є помилкою, але це, звичайно, те, що ми можемо обійти. Я додав до полів префікс підкресленням, щоб позначити «тільки для внутрішнього використання».

Оскільки «тип» рядка є []u8 або []const u8, ArrayList(u8) є відповідним типом для конструктора рядків, наприклад StringBuilder .NET або strings.Builder Go's . Фактично, ви часто будете використовувати це, коли функція приймає Writer, а вам потрібен рядок. Раніше ми бачили приклад використання std.json.stringify для виведення JSON на stdout. Ось як можна використати ArrayList(u8) для виведення його в змінну:

learning.zig

const std = @import("std");

pub fn main() !void {
  var gpa = std.heap.GeneralPurposeAllocator(.{}){};
  const allocator = gpa.allocator();

  var out = std.ArrayList(u8).init(allocator);
  defer out.deinit();

  try std.json.stringify(.{
    .this_is = "an anonymous struct",
    .above = true,
    .last_param = "are options",
  }, .{.whitespace = .indent_2}, out.writer());

  std.debug.print("{s}\n", .{out.items});
}

Anytype

У частині 1 ми коротко говорили про anytype. Це досить корисна форма качиного введення під час компіляції. Ось простий реєстратор:

pub const Logger = struct {
  level: Level,

  // "error" зарезервовано, імена в середині @"..." завжди
  // використовуються як ідентифікатори
  const Level = enum {
    debug,
    info,
    @"error",
    fatal,
  };

  fn info(logger: Logger, msg: []const u8, out: anytype) !void {
    if (@intFromEnum(logger.level) <= @intFromEnum(Level.info)) {
      try out.writeAll(msg);
    }
  }
};

Параметр out нашої функції info має тип anytype. Це означає, що наш Logger може реєструвати повідомлення до будь-якої структури, яка має метод writeAll, який приймає []const u8 і повертає !void. Це не функція часу виконання. Перевірка типів відбувається під час компіляції, і для кожного використаного типу створюється правильно введена функція. Якщо ми спробуємо викликати info з типом, який не має всіх необхідних функцій (у цьому випадку просто writeAll), ми отримаємо помилку часу компіляції:

var l = Logger{.level = .info};
try l.info("сервер запущено", true);

Надає нам: no field or member function named 'writeAll' in 'bool'.. Використання writer ArrayList(u8) працює:

pub fn main() !void {
  var gpa = std.heap.GeneralPurposeAllocator(.{}){};
  const allocator = gpa.allocator();

  var l = Logger{.level = .info};

  var arr = std.ArrayList(u8).init(allocator);
  defer arr.deinit();

  try l.info("скрвер запущено", arr.writer());
  std.debug.print("{s}\n", .{arr.items});
}

Одним із значних недоліків anytype є документація. Ось підпис для функції std.json.stringify, яку ми використовували кілька разів:

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

fn stringify(
  value: anytype,
  options: StringifyOptions,
  out_stream: anytype
) @TypeOf(out_stream).Error!void

Перший параметр, value: anytype начебто очевидний. Це значення для серіалізації, і воно може бути будь-яким (насправді є деякі речі, які JSON-серіалізатор Zig не може серіалізувати). Ми можемо здогадатися, що out_stream є де для запису JSON, але ваші припущення такі ж хороші, як і мої щодо методів, які потрібно реалізувати. Єдиний спосіб зрозуміти це — прочитати вихідний код або, альтернативно, передати фіктивне значення та використати помилки компілятора як нашу документацію. Це те, що можна покращити за допомогою кращих автоматичних генераторів документів. Але не вперше я хотів би, щоб Zig мав інтерфейси.

@TypeOf

У попередніх частинах ми використовували @TypeOf, щоб допомогти нам перевірити тип різних змінних. З нашого використання, вам буде пробачено, якщо ви подумаєте, що він повертає назву типу як рядок. Однак, враховуючи, що це функція PascalCase, ви повинні знати краще: вона повертає type.

Одне з моїх улюблених застосувань anytype — це поєднання його з вбудованими функціями @TypeOf і @hasField для написання помічників тестів. Хоча кожен тип користувача, який ми бачили, був дуже простим, я попрошу вас уявити більш складну структуру з багатьма полями. У багатьох наших тестах нам потрібен User, але ми хочемо вказати лише поля, які стосуються тесту. Давайте створимо userFactory:

fn userFactory(data: anytype) User {
  const T = @TypeOf(data);
  return .{
    .id = if (@hasField(T, "id")) data.id else 0,
    .power = if (@hasField(T, "power")) data.power else 0,
    .active = if (@hasField(T, "active")) data.active else true,
    .name = if (@hasField(T, "name")) data.name else "",
  };
}

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

Користувача за замовчуванням можна створити, викликавши userFactory({}), або ми можемо перевизначити певні поля за допомогою userFactory(.{.id = 100, .active = false}). Це маленький візерунок, але він мені дуже подобається. Це також гарний дитячий крок у світ метапрограмування.

Найчастіше @TypeOf поєднується з @typeInfo, який повертає std.builtin.Type. Це потужне об’єднання тегів, яке повністю описує тип. Функція std.json.stringify рекурсивно використовує це в наданому value, щоб визначити, як його серіалізувати.

Саситема Зборки Zig

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


Правда в тому, що я недостатньо добре розумію систему збірки Zig, щоб пояснити її.


Тим не менш, ми можемо отримати принаймні короткий огляд. Щоб запустити наш Zig-код, ми використали zig run learning.zig. Одного разу ми також використали zig test learning.zig для запуску тесту. Команди run і test чудово підходять для гри, але це команда build, яка вам знадобиться для чогось більш складного. Команда build покладається на файл build.zig зі спеціальною точкою входу build. Ось скелет:

learning.zig

const std = @import("std");

pub fn build(b: *std.Build) !void {
  _ = b;
}

Кожна збірка має типовий крок «встановлення», який тепер можна виконати за допомогою zig build install, але оскільки наш файл здебільшого порожній, ви не отримаєте жодних значущих артефактів. Нам потрібно повідомити нашій збірці про точку входу нашої програми, яка знаходиться в learning.zig:

learning.zig

const std = @import("std");

pub fn build(b: *std.Build) !void {
  const target = b.standardTargetOptions(.{});
  const optimize = b.standardOptimizeOption(.{});

  // налаштування виконуваного файлу
  const exe = b.addExecutable(.{
    .name = "learning",
    .target = target,
    .optimize = optimize,
    .root_source_file = b.path("learning.zig"),
  });
  b.installArtifact(exe);
}

Тепер, якщо ви запустите zig build install, ви отримаєте двійковий файл у ./zig-out/bin/learning. Використання стандартних цілей і оптимізацій дозволяє нам перевизначати значення за замовчуванням як аргументи командного рядка. Наприклад, щоб створити оптимізовану за розміром версію нашої програми для Windows, ми б зробили:

zig build install -Doptimize=ReleaseSmall -Dtarget=x86_64-windows-gnu

Виконуваний файл часто має два додаткові кроки, окрім install за замовчуванням: run та test. Бібліотека може мати один test крок. Для базового run без аргументів нам потрібно додати чотири рядки в кінець нашої збірки:

// додайте після: b.installArtifact(exe);

const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());

const run_step = b.step("run", "Start learning!");
run_step.dependOn(&run_cmd.step);

Це створює дві залежності за допомогою двох викликів dependOn. Перший прив’язує нашу нову команду запуску до вбудованого кроку встановлення. Другий пов’язує крок «виконати» з новоствореною командою «виконати». Вам може бути цікаво, навіщо вам потрібна команда запуску, а також крок запуску. Я вважаю, що це розділення існує для підтримки більш складних налаштувань: кроків, які залежать від кількох команд, або команд, які використовуються в кількох кроках. Якщо ви запустите zig build --help і прокрутите догори, ви побачите наш новий крок "виконати". Тепер ви можете запустити програму, виконавши zig build run.

Щоб додати «тестовий» крок, ви скопіюєте більшу частину коду запуску, який ми щойно додали, але замість b.addExecutable, ви почнете роботу з b.addTest:

const tests = b.addTest(.{
  .target = target,
  .optimize = optimize,
  .root_source_file = b.path("learning.zig"),
});

const test_cmd = b.addRunArtifact(tests);
test_cmd.step.dependOn(b.getInstallStep());
const test_step = b.step("test", "Run the tests");
test_step.dependOn(&test_cmd.step);

Ми дали цьому кроку назву «тест». Запуск zig build --help має показати ще один доступний крок, "test". Оскільки у нас немає тестів, важко сказати, працює це чи ні. У learning.zig додайте:

test "dummy build test" {
  try std.testing.expectEqual(false, true);
}

Тепер, коли ви запускаєте zig build test, ви повинні отримати помилку тесту. Якщо ви виправите тест і знову запустите zig build test, ви не отримаєте результату. За замовчуванням тестовий бігун Zig виводить лише у разі помилки. Використовуйте zig build test --summary all, якщо, як і я, вам завжди потрібен підсумок, незалежно від того, пройдено чи не пройдено.

Це мінімальна конфігурація, яка вам знадобиться для початку роботи. Але будьте спокійні, знаючи, що якщо вам потрібно його створити, Зіг, ймовірно, впорається з цим. Нарешті, ви можете і, мабуть, повинні використовувати zig init у корені вашого проекту, щоб Zig створив для вас добре задокументований файл build.zig.

Сторонні Залежності

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

Спочатку створіть нову папку під назвою calc і створіть три файли. Перший – це add.zig з таким вмістом:

// Ой, прихований урок, подивіться на вид б
// і тип повернення!!

pub fn add(a: anytype, b: @TypeOf(a)) @TypeOf(a) {
  return a + b;
}

const testing = @import("std").testing;

test "add" {
  try testing.expectEqual(@as(i32, 32), add(30, 2));
}

Це трохи безглуздо, цілий пакет просто додати два значення, але це дозволить нам зосередитися на аспекті упаковки. Далі ми додамо не менш дурний: calc.zig:

pub const add = @import("add.zig").add;

test {
  // За замовчуванням лише тести у вказаному файлі
  // включені. Цей чарівний рядок коду буде
  // викликати посилання на всі вкладені контейнери
  // для перевірки.
  @import("std").testing.refAllDecls(@This());
}

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

learning.zig

const std = @import("std");

pub fn build(b: *std.Build) !void {
  const target = b.standardTargetOptions(.{});
  const optimize = b.standardOptimizeOption(.{});

  const tests = b.addTest(.{
    .target = target,
    .optimize = optimize,
    .root_source_file = b.path("calc.zig"),
  });

  const test_cmd = b.addRunArtifact(tests);
  test_cmd.step.dependOn(b.getInstallStep());
  const test_step = b.step("test", "Run the tests");
  test_step.dependOn(&test_cmd.step);
}

Це все повторення того, що ми бачили в попередньому розділі. З цим ви можете запустити zig build test --summary all.

Повернемося до нашого навчального проекту та раніше створеного build.zig. Ми почнемо з додавання нашого локального calc як залежності. Нам потрібно зробити три доповнення. Спочатку ми створимо модуль, який вказує на наш calc.zig:

// Ви можете розмістити це у верхній частині збірки
// функція перед викликом add Executable.

const calc_module = b.addModule("calc", .{
  .root_source_file = b.path("PATH_TO_CALC_PROJECT/calc.zig"),
});

Вам потрібно буде змінити шлях до calc.zig. Тепер нам потрібно додати цей модуль до наших існуючих змінних exe і tests. Оскільки наш build.zig стає більш зайнятим, ми спробуємо трохи впорядкувати речі:

const std = @import("std");

pub fn build(b: *std.Build) !void {
  const target = b.standardTargetOptions(.{});
  const optimize = b.standardOptimizeOption(.{});

  const calc_module = b.addModule("calc", .{
    .root_source_file = b.path("PATH_TO_CALC_PROJECT/calc.zig"),
  });

  {
    // налаштовуємо "run" команду
    const exe = b.addExecutable(.{
      .name = "learning",
      .target = target,
      .optimize = optimize,
      .root_source_file = b.path("learning.zig"),
    });
    // додайте це
    exe.root_module.addImport("calc", calc_module);
    b.installArtifact(exe);

    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());

    const run_step = b.step("run", "Start learning!");
    run_step.dependOn(&run_cmd.step);
  }

  {
    // налаштовуємо "test" команду
    const tests = b.addTest(.{
      .target = target,
      .optimize = optimize,
      .root_source_file = b.path("learning.zig"),
    });
    // додайте це
    tests.root_module.addImport("calc", calc_module);

    const test_cmd = b.addRunArtifact(tests);
    test_cmd.step.dependOn(b.getInstallStep());
    const test_step = b.step("test", "Run the tests");
    test_step.dependOn(&test_cmd.step);
  }

}

Зі свого проекту тепер ви можете @import("calc"):

const calc = @import("calc");
...
calc.add(1, 2);

Додавання віддаленої залежності вимагає трохи більше зусиль. По-перше, нам потрібно повернутися до проекту calc і визначити модуль. Ви можете подумати, що проект сам по собі є модулем, але проект може виставляти кілька модулів, тому нам потрібно явно створити його. Ми використовуємо той самий addModule, але відкидаємо повернуте значення. Простого виклику addModule достатньо, щоб визначити модуль, який потім зможуть імпортувати інші проекти.

_ = b.addModule("calc", .{
  .root_source_file = b.path("calc.zig"),
});

Це єдина зміна, яку нам потрібно внести в нашу бібліотеку. Оскільки це вправа мати віддалену залежність, я передав цей проект calc на Github, щоб ми могли імпортувати його в наш навчальний проект. Він доступний на https://github.com/karlseguin/calc.zig.

У нашому навчальному проекті нам потрібен новий файл, build.zig.zon. «ZON» розшифровується як нотація об’єктів Zig і дозволяє виражати дані Zig у форматі, зрозумілому людині, і перетворювати цей формат на Zig-код. Вміст build.zig.zon буде таким:

.{
  .name = "learning",
  .paths = .{""},
  .version = "0.0.0",
  .dependencies = .{
    .calc = .{
      .url = "https://github.com/karlseguin/calc.zig/archive/d1881b689817264a5644b4d6928c73df8cf2b193.tar.gz",
      .hash = "12ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
    },
  },
}

У цьому файлі є два сумнівних значення, перше – d1881b689817264a5644b4d6928c73df8cf2b193 в url. Це просто хеш git commit. Друге - це значення "хеш". Наскільки я знаю, наразі немає чудового способу визначити, яким має бути це значення, тому поки що ми використовуємо фіктивне значення.

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

// замініть це:
const calc_module = b.addModule("calc", .{
  .root_source_file = b.path("calc/calc.zig"),
});

// цим:
const calc_dep = b.dependency("calc", .{.target = target, .optimize = optimize});
const calc_module = calc_dep.module("calc");

У build.zig.zon ми назвали залежність calc, і це залежність, яку ми тут завантажуємо. З цієї залежності ми беремо модуль calc, який ми назвали модуль у build.zig calc.

Якщо ви спробуєте запустити zig build test, ви повинні побачити помилку:

hash mismatch: manifest declares
122053da05e0c9348d91218ef015c8307749ef39f8e90c208a186e5f444e818672da

but the fetched package has
122036b1948caa15c2c9054286b3057877f7b152a5102c9262511bf89554dc836ee5

Скопіюйте та вставте правильний хеш назад у build.zig.zon і спробуйте знову запустити zig build test. Тепер все повинно працювати.

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

Коротке попередження: я виявив, що кешування залежностей Zig є агресивним. Якщо ви намагаєтеся оновити залежність, але Zig, здається, не виявляє зміни... добре, я видаляю папку zig-cache проекту, а також ~/.cache/zig.


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