#
Глава IX. Программируем на языке 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's power is: {d}\n", .{entry.power});
}
const User = struct {
power: i32,
};
Запустим:
$ /opt/zig-0.11/zig run src/ex-ch09-01.zig
Goku's power is: 9001
Goku's power is: -1431655766
В этом примере мы знакомимся с обобщённой хэш-таблицей
(std.StringHashMap
), которая является специализированной версией
std.AutoHashMap
с типом ключа []const u8
. Даже если вы не уверены на все
сто относительно того, что конкретно происходит при выводе, наверняка вы
поняли, что это однозначно связано с тем, что второй вызов print
делается после того, как мы уже удалили элемент из таблицы. Если убрать
вызов remove
, всё будет нормально.
Чтобы полностью понимать этот пример, нужно чётко представлять себе,
где находятся данные или, иными словами, кто ими владеет. Как мы
знаем, аргументы в функцию передаются по значению, то есть мы передаём
копию (возможно, поверхностную) значения переменной. Экземпляр User
в
таблице lookup
и user
находятся в разных местах памяти, то есть в
этом коде у нас два экземпляра пользователя, каждый со своим
"владельцем". goku
находится во владении у функции main
, а его копией
владеет сама таблица lookup
.
Метод getPtr
возвращает указатель на экземпляр в таблице (*User
в нашем примере).
Собственно, в этом и "проблема": после вызова remove
указатель
entry
перестаёт показывать в правильное место. В примере у нас
getPtr
и remove
расположены в тексте близко друг к другу,
поэтому практически очевидно, в чём тут ошибка. Но совсем несложно
представить себе код, вызывающий remove
без знания того,
что ссылка на элемент есть где-то ещё.
Помимо удаления ошибочного вызова remove
мы можем починить наш пример и
другими способами. Первый способ это использовать метод get
вместо
getPtr
. Этот метод возвращает копию User
, а не указатель на
экземпляр, который находится в самой таблице. И тогда у нас будет
три экземпляра User
:
- исходный
goku
, созданный вmain
- его копия в
lookup
, которая и владеет этой копией - и копия копии,
entry
, ею владеет такжеmain
Поскольку теперь entry
это независимая копия,
удаление из таблицы ничего с ней не сделает.
Другой возможностью является изменить тип у таблицы с StringHashMap(User)
на StringHashMap(*const User)
. Вот такой код будет работать правильно:
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's power is: {d}\n", .{entry.power});
_ = lookup.remove("Goku");
std.debug.print("Goku's power is: {d}\n", .{entry.power});
}
const User = struct {
power: i32,
};
В этом коде имеется несколько тонкостей. Прежде всего, теперь у нас
только один экземпляр пользователя. В таблице и в переменной entry
у
нас теперь ссылки на этот экземпляр. Вызов reomve
, как и прежде, всё
так же удаляет элемент из таблицы, но там у нас всего лишь указатель,
адрес переменной user
, а не его полная копия. Если бы в последнем
варианте использовали бы getPtr
(а не get
), после remove
указатель
entry
(который тогда бы был **User
), тоже стал бы "сломанным". Оба
наши решения требуют использования get
(а не getPtr
), просто во
втором его варианте мы делаем копию адреса, а не всей структуры User
.
Для больших объектов это может иметь серьёзное значение в плане влияния
на производительность.
Когда у нас всё происходит внутри одной функции и при этом наша структура
User
невелика по размеру, всё это выглядит не иначе, как искусственно
созданная проблема. Поэтому нам нужен пример, который показал бы, что
вопросы владения являются первоочередной заботой.
#
Владение
Про хэш-таблицы знают все и все ими пользуются, они имеют массу применений, со многими из которых вы, вероятно, имели дело в своей практике. Хотя они и могут использоваться как короткоживущие сущности, но, как правило, они всё таки живут длительное время и поэтому требуют столь же долгоживущих значений, которые вы помещаете в таблицы.
В следующем примере хэш-таблица наполняется именами, которые пользователь вводит с клавиатуры в терминале. Пустое имя завершает цикл ввода. После этого программа проверяет, было ли среди введённых имён имя "Leto".
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("Please enter a name: ", .{});
if (try stdin.readUntilDelimiterOrEof(&buf, '\n')) |line| {
var name = line;
if (builtin.os.tag == .windows) {
// в Windows на концах строк \r\n.
// нам нужно убрать \r
name = 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,
};
Программа учитывает регистр, но как бы аккуратно вы не вводили "Leto",
метод contains
будет всегда возвращать false
. Давайте попробуем
отладить нашу программу, добавив для начала печать всех ключей
и значений после цикла ввода:
// поместите этот код сразу после цикла while
var it = lookup.iterator();
while (it.next()) |kv| {
std.debug.print("{s} == {any}\n", .{kv.key_ptr.*, kv.value_ptr.*});
}
Такой шаблон для работы с итераторами типичен в Zig, и опирается он на
тесное взаимодействие между циклом while
и необязательными значениями.
Итератор it
возвращает указатели на ключ и на значение, поэтому мы
используем здесь разыменование (.*
) для того, чтобы получить значение,
имея его адрес. Что ж, запускаем:
$ /opt/zig-0.11/zig run src/ex-ch09-03-debug.zig
Please enter a name: Paul
Please enter a name: Teg
Please enter a name: Leto
Please enter a name:
�� == ex-ch09-03-debug.User{ .power = 1 }
��� == ex-ch09-03-debug.User{ .power = 0 }
��� == ex-ch09-03-debug.User{ .power = 2 }
false
Значения вроде бы выглядят нормально, а вот с ключами у нас тут явно
что-то не то. Выше было сказано, что значения должны жить не меньше, чем
сама хэш-таблица. Но и ключи таблицы тоже должны удовлетворять этому правилу!.
Буфер buf
у нас определён внутри цикла while
. Когда мы вызываем put
,
мы передаём ключ (имя пользователя), который живёт меньше, чем таблица.
Если мы поместим объявление буфера перед циклом, это решит вопрос
с его временем жизни, но буфер-то всё равно будет переиспользоваться
на каждой итерации цикла и поэтому программа всё равно не будет работать
как надо, поскольку мы всё время изменяем значение ключа.
Для нашего примера есть только одно решение: хэш-таблица должна владеть не только значениями, но и ключами. Для этого нужно сделать следующие изменения:
// замените lookup.put на эти две строчки
const owned_name = try allocator.dupe(u8, name);
try lookup.put(owned_name, .{.power = i});
Метод dupe
аллокатора, который ранее нам ещё не встречался, делает
дубликат, выделяя под него память. Теперь наш код будет работать
правильно, потому что ключи теперь размещены в куче и живут в любом
случае не меньше, чем хэш-таблица. Но есть одно но: теперь у нас
появились утечки памяти.
Возможно, вы подумали, что когда мы вызываем lookup.deinit
, память под
ключи и значения будет освобождена сама собой. Увы, StringHashMap
не
может так сделать. Во-первых, ключи могут быть строковыми литералами,
тогда строки хранятся в сегменте данных, а не в куче и их в принципе
невозможно убрать. Во-вторых, память под строки-ключи может быть выделена
другим аллокатором. И наконец, могут быть вполне законные сценарии, когда
ключи не должны быть во владении у хэш-таблицы.
Поэтому единственное, что мы тут можем сделать, это освободить память под
ключи самостоятельно. Здесь, видимо, стоило бы подумать о создании своего
типа UserLookup
и поместить логику с очисткой в деструктор deinit
, но
мы пока так, небрежно:
// замените defer lookup.deinit(); на это:
defer {
var it = lookup.keyIterator();
while (it.next()) |key| {
allocator.free(key.*);
}
lookup.deinit();
}
Здесь мы впервые использовали defer
с блоком, внутри которого мы
сначала чистим память, занимаемую ключами и затем, как и раньше, вызываем
деструктор таблицы. Обратите также внимание, что тут используется
keyIterator
, чтобы пройтись только по ключам, значения нам тут не
нужны. Значение итератора it
это указатель на ключ в таблице, то есть
это *[]const u8
. Поэтому, чтобы освободить память, здесь опять нужно
использовать разыменование (key.*
).
Всё, достаточно с нас висячих указателей и управления памятью! То, что мы
обсуждали в этом разделе, по-прежнему может казаться не совсем ясным или
слишком абстрактным. Но, тем не менее, если вы планируете писать что-то
нетривиальное, можно совершенно определённо сказать, что управление
памятью это то, чем вы будете должны овладеть в совершенстве. В этой
связи будет весьма полезно проделывать всяческие упражнения с кодом из
этого раздела. Например, сделайте тип UserLookup
, который содержал бы в
себе весь код, связанный с динамической памятью. Попробуйте держать в
таблице указатели (*User
), то есть выделяйте память под значения
таблицы тоже в куче и освобождайте её так, как мы это проделали с
ключами. Пишите тесты с использованием std.testing.allocator
, чтобы
убедиться в отсутствии утечек памяти.
#
Динамический массив ArrayList
Определённо, вы будете рады узнать, что можно забыть про наши упражнения
с IntList
и обобщённым вариантом List
, потому что в стандартной
библиотеке Zig есть надлежащая реализация обобщённого динамического массива,
std.ArrayList(T)
.
Динамические массивы как таковые это вполне стандартная вещь,
но, поскольку эта структура данных весьма часто используется,
стоит поглядеть, каков ArrayList
в действии:
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("Please enter a name: ", .{});
if (try stdin.readUntilDelimiterOrEof(&buf, '\n')) |line| {
var name = line;
if (builtin.os.tag == .windows) {
name = 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);
}
};
В этом примере делается всё то же самое, как и ранее, но только вместо
StringHashMap(User)
используется ArrayList(User)
. Все правила
относительно времён жизни и работы с кучей по прежнему в силе. Обратите
внимание, что мы, как и раньше, делаем копию ключей при помощи dupe
и,
как раньше, освобождаем память, занимаемую этими копиями, перед тем, как
вызвать deinit
для ArrayList
.
И сейчас самое время сказать, что в Zig нет так называемых "свойств"
(properties), равно как и приватных полей. Это можно видеть из кода,
который обращается к arr.items
для прохода по значениям. Причина, по
которой в Zig нет свойств (это такие поля структуры/класса, которые
снаружи используются как обычные поля, но на самом деле это функции),
состоит в том, чтобы устранить возможный источник сюрпризов. В Zig, если
что-то выглядит как обращение к полю, это действительно обращение к полю
и, соответственно, если что-то не выглядит как вызов функции, то это не
вызов функции. С другой стороны, отсутствие приватных полей, возможно,
ошибка дизайна Zig, но мы можем это как-то нивелировать, например,
используя символ _
в качестве первого символа имён тех полей, которые
предназначены только для внутреннего использования.
Поскольку строки имеют тип []8
или []const u8
, список из байтов (то
есть ArrayList(u8)
) это подходящий тип для построения конструктора
строк по типу StringBuilder
в .NET или strings.Builder
в Go.
Фактически, вы будете часто такое использовать в случаях, когда функция
принимает Writer
и вам на выходе нужна строка. Ранее мы видели пример,
в котором для вывода документа JSON на стандартный вывод использовалась
std.json.stringify
. Вот пример использования ArrayList(u8)
для вывода
в переменную:
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
Мы уже вскользь упоминали anytype
в Главе I. Такой "тип" это весьма
полезная форма утиной (неявной) типизации во время компиляции. Вот
простой логгер:
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
.
Проверка типов производится во время компиляции
(а не во время выполнения программы): для каждого
типа, использованного в качестве второго параметра Logger.info
,
проверяется, есть ли у этого типа метод writeAll
.
Если мы попытаемся вызвать info
с типом, который
не имеет всех нужных функций (в нашем случае одной),
то мы получим ошибку компиляции:
var l = Logger{.level = .info};
try l.info("sever started", true);
Компилятор скажет, что у типа bool
нет поля с именем writeAll
.
Использование 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("sever started", 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
вроде как очевиден: это что-то, что
нужно сериализовать и это что-то может быть чем угодно (на самом деле в
Zig существуют некоторые вещи, которые сериализатор JSON сериализовать не
сможет). А вот что касается третьего параметра, то, конечно, мы можем
догадываться, что это то, куда будет выведен документ, но для того,
чтобы понять, какие у этой сущности должны быть методы, нужно или глядеть
в исходный текст stringify
или передать что-то первое под руку
попавшееся и использовать в качестве документации текст ошибок
компиляции.
#
@TypeOf
Ранее мы использовали эту встроенную функцию для того, чтобы посмотреть,
какой тип имеет та или иная переменная. Из того, как именно мы её
использовали, могло показаться, что она возвращает название типа
переменной в виде строки, но нет - судя по тому, что для имени этой
функции использован PascalCase
, она возвращает type
, то есть тип.
Одно из применений anytype
это скомбинировать такой тип с
@TypeOf
и @hasField
для написания тестовых вспомогательных функций.
Хотя все типы User
, которые мы использовали, были весьма простыми,
вообразите более сложную структуру, в которой много-много полей.
Во многих возможных тестах нам будет нужен экземпляр User
,
но при этом мы хотим указать только те поля, которые имеют
отношение к данному конкретному тесту. Давайте создадим
фабрику пользователей:
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
(к примеру)
рекурсивно использует такие описания для того, чтобы выяснить, как именно
нужно сериализовать значения.
#
Система сборки
Если вы читали всю эту книгу и при этом ожидали каких-то рецептов для работы с более сложными проектами (множественные зависимости, поддержка разных целевых архитектур и т.п), то увы, вас ждёт разочарование. Zig на самом деле имеет очень мощную систему сборки, настолько мощную, что она начала использоваться для проектов, написанных не на Zig. К сожалению, вся эта мощь означает, что для более простых нужд система сборки Zig не самая простая в использовании.
Тем не менее, небольшой обзор мы всё же сделаем. Чтобы запускать наши
примеры, мы использовали zig run file.zig
. Однажды мы также
использовали zig test file.zig
для запуска теста. Команды run
и
test
хороши для всяких простых упражнений, но для чего-то более
серьёзного вам понадобится команда build
. Для этой команды нужно, чтобы
в корневом каталоге проекта был файл build.zig
со специальной
одноимённой точкой входа:
// build.zig
const std = @import("std");
pub fn build(b: *std.Build) !void {
_ = b;
}
Каждая сборка имеет по умолчанию этап "install", который можно
выполнить при помощи zig build install
, но поскольку наш файл
build.zig
практически пуст, на выходе ничего особо значимого и не
будет. Как минимум, мы должны сообщить, где у нас файл с функцией main
(тут предполагается, что это program.zig
):
const std = @import("std");
pub fn build(b: *std.Build) !void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// setup executable
const exe = b.addExecutable(.{
.name = "program",
.target = target,
.optimize = optimize,
.root_source_file = .{ .path = "program.zig" },
});
b.installArtifact(exe);
}
Теперь, если вы выполните zig build install
, то получите
скомпилированный исполнимый файл ./zig-out/bin/program
. Целевую
платформу и вид оптимизации при этом можно указать в аргументах командной
строки. Например, если мы хотим оптимизацию по размеру и делаем
исполнимый файл для ОС Windows на архитектуре x86_64, то делаем так:
zig build install -Doptimize=ReleaseSmall -Dtarget=x86_64-windows-gnu
Помимо этапа "install", процесс сборки может включать в себя ещё два, "run" и "test". Если это библиотека, то только один, "test". Чтобы включить этап "run", в простейшем случае, то есть без передачи программе аргументов, нужно добавить 4 строчки:
// add after: 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
. Первый из них
привязывает команду "run" к встроенному этапу "install". Второй
привязывает этап "run" к созданной команде "run". Вы, наверное, спросите,
зачем нужны как команда "run", так и этап "run". Надо полагать, такое
разделение существует для возможности более сложных конфигураций: этапы,
которые зависят от более чем одной команды или команды, которые
используются на разных этапах. Если вы выполните zig build --help
и
посмотрите на начало вывода, то увидите наш новый этап "run". Теперь вы
можете запускать программу при помощи zig build run
.
Для добавления этапа "test" нужно продублировать практически весь код для
этапов "install" и "run", только вместо b.addExecutable
надо
использовать b.addTest
:
const tests = b.addTest(.{
.target = target,
.optimize = optimize,
.root_source_file = .{ .path = "program.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);
Тут мы дали этапу имя "test". При запуске zig build --help
теперь мы
должны увидеть новый доступный этап. Если в нашей program.zig
никаких
тестов нет, сложно сказать, что тут получится, поэтому добавим в
program.zig
заглушку:
test "dummy build test" {
try std.testing.expectEqual(false, true);
}
Теперь, если вы выполните zig build test
, то увидите, что тест не
прошёл, как и должно было быть, поскольку true != false
. Если вы
подправите тест и снова запустите zig build test
, то ничего не
выведется, что и будет означать, что тест прошёл. По умолчанию система
тестов Zig что либо выводит только в случае неудавшегося теста. Если
нужно в любом случае что-то увидеть, используйте zig build test --summary all
.
В заключение отметим, что начинать проект можно (и, наверное, нужно) с
выполнения zig init-exe
или zig init-lib
для запускаемых программ и
библиотек, соответственно. Эти команды создадут надлежащий build.zig
автоматически, причём хорошо документированный.
#
Сторонние зависимости
Встроенный в Zig менеджер пакетов пока ещё относительно новый и, но его вполне можно использовать. Далее мы рассмотрим два вопроса, создание пакетов и использование пакетов.
Сначала создадим каталог calc
и в нём три файла, первый из которых
назовём add.zig
, вот его содержимое:
// О, а тут у нас скрытый урок,
// посмотрите на тип b и на тип возвращаемого значения
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());
}
Мы разместили эти два кусочка кода в разных файлах
для того, чтобы zig build
автоматически собрал
и "упаковал" все файлы нашего проекта. Добавляем build.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 = .{ .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 Summary: 4/4 steps succeeded; 2/2 tests passed
test success
└─ run test 2 passed 1ms MaxRSS:1M
├─ zig test Debug native success 2s MaxRSS:185M
└─ install cached
Вернёмся теперь к примеру из предыдущего раздела и к файлу build.zig
,
который мы там сделали. Начнём с добавления в него нашего локального
calc
в качестве зависимости, для чего нужно сделать три дополнения.
Сначала создадим модуль, указывающий на calc
:
// можно разместить это недалеко от начала функции build,
// перед вызовом addExecutable.
const calc_module = b.addModule("calc", .{
.source_file = .{ .path = "PATH_TO_CALC_PROJECT/calc.zig" },
});
Теперь нужно добавить этот модуль к переменным exe
и tests
:
const exe = b.addExecutable(.{
.name = "learning",
.target = target,
.optimize = optimize,
.root_source_file = .{ .path = "learning.zig" },
});
// добавляем это
exe.addModule("calc", calc_module);
b.installArtifact(exe);
....
const tests = b.addTest(.{
.target = target,
.optimize = optimize,
.root_source_file = .{ .path = "learning.zig" },
});
// и это
tests.addModule("calc", calc_module);
Теперь в вашем проекте можно импортировать и использовать модуль:
const calc = @import("calc");
...
calc.add(1, 2);
Добавление удалённой зависимости требует немного больше усилий. Сначала
вернёмся к проекту calc
, там нам нужно определить модуль. Вы можете
подумать, что проект сам по себе и есть модуль, но всё немного сложнее:
проект может предоставлять несколько модулей, поэтому модуль надо явно
обозначить. Для этого используется всё та же addModule
, но возвращаемое
значение надо просто отбросить. Таким образом, вызова addModule
вполне
достаточно для определения модуля, который может импортироваться другими
проектами.
_ = b.addModule("calc", .{
.source_file = .{ .path = "calc.zig" },
});
Поскольку это упражнение в использовании удалённых зависимостей,
проект calc
помещён на GitHub и доступен по ссылке
https://github.com/karlseguin/calc.zig.
Теперь возвращаемся к проекту, в котором мы будем использовать эту
удалённую зависимость. Нам понадобится новый файл, который будет
называться build.zig.zon
. "ZON" означает "Zig Object Notation", то есть
система обозначений объектов Zig. Эта система позволяет описывать данные
Zig в форме, понятной человеку и наоборот, трансформировать данные из
этой формы в код Zig. В этом нашем файле будет вот что:
.{
.name = "program",
.paths = .{""},
.version = "0.0.0",
.dependencies = .{
.calc = .{
.url = "https://github.com/karlseguin/calc.zig/archive/e43c576da88474f6fc6d971876ea27effe5f7572.tar.gz",
.hash = "12ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
},
},
}
Тут мы видим два "подозрительных" значения. Первое, которое в URL
(e43c576da88474f6fc6d971876ea27effe5f7572
) это не что иное, как хэш
коммита системы git
. Со вторым (значение поля hash
) сложнее: вроде
как на данный момент нет нормального способа узнать, чему должно быть
равно это поле. Но нам это и не нужно. Как так, спросите вы?
Нам поможет встроенный в zig менеджер пакетов, а именно команда fetch
. Для добавления стороннего пакета
в наши зависимости в нем должен быть файл build.zig.zon
. В таком случае достаточно запустить команду:
zig fetch --save git+https://github.com/karlseguin/http.zig
Как правило, авторы пакетов подробно описывают, каким образом можно добавить пакет в свой проект.
Небольшое предупреждение: Zig иногда может не замечать изменения в
зависимостях. Если вы пытаетесь обновить зависимость, но Zig как будто бы
этого не хочет замечать... что ж, просто сотрите каталог zig-cache
в
проекте, а также ~/.cache/zig
.