Files
learning-zig-rus/src/ch09.md
zed f3f55e5be8 On branch main
modified:   src/ch09.md
2023-11-21 14:27:16 +03:00

45 KiB
Raw Blame History

Программируем на языке 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);

    // returns an optional, .? would panic if "Goku"
    // wasn't in our hashmap
    const entry = lookup.getPtr("Goku").?;

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

    // returns true/false depending on if the item was removed
    _ = 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 is an std.io.Reader
    // the opposite of an std.io.Writer, which we already saw
    const stdin = std.io.getStdIn().reader();

    // stdout is an 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) {
                // In Windows lines are terminated by \r\n.
                // We need to strip out the \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. Давайте попробуем отладить нашу программу, добавив для начала печать всех ключей и значений после цикла ввода:

// Place this code after the while loop

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: 

<0A><> == ex-ch09-03-debug.User{ .power = 1 }

<0A><><EFBFBD> == ex-ch09-03-debug.User{ .power = 0 }

<0A><><EFBFBD> == ex-ch09-03-debug.User{ .power = 2 }
false

Значения вроде бы выглядят нормально, а вот с ключами у нас тут явно что-то не то. Выше было сказано, что значения должны жить не меньше, чем сама хэш-таблица. Но и ключи таблицы тоже должны удовлетворять этому правилу!. Буфер buf у нас определён внутри цикла while. Когда мы вызываем put, мы передаём ключ (имя пользователя), который живёт меньше, чем таблица. Если мы поместим объявление буфера перед циклом, это решит вопрос с его временем жизни, но буфер-то всё равно будет переиспользоваться на каждой итерации цикла и поэтому программа всё равно не будет работать как надо, поскольку мы всё время изменяем значение ключа.

Для нашего примера есть только одно решение: хэш-таблица должна владеть не только значениями, но и ключами. Для этого Нужно сделать следующие изменения:

// replace the existing lookup.put with these two lines
const owned_name = try allocator.dupe(u8, name);

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

Метод dupe аллокатора, который ранее нам ещё не встречался, делает дубликат, выделяя под него память. Теперь наш код будет работать правильно, потому что ключи теперь размещены в куче и живут в любом случае не меньше, чем хэш-таблица. Но есть одно но: теперь у нас появились утечки памяти.

Возможно, вы подумали, что когда мы вызываем lookup.deinit, память под ключи и значения будет освобождена сама собой. Увы, StringHashMap не может так сделать. Во-первых, ключи могут быть строковыми литералами, тогда строки хранятся в сегменте данных, а не в куче и их в принципе невозможно убрать. Во-вторых, память под строки-ключи может быть выделена другим аллокатором. И наконец, могут быть вполне законные сценарии, когда ключи не должны быть во владении у хэш-таблицы.

Поэтому единственное, что мы тут можем сделать, это освободить память под ключи самостоятельно. Здесь, видимо, стоило бы подумать о создании своего типа UserLookup и поместить логику с очисткой в деструктор deinit, но мы пока так, небрежно:

// replace the existing:
//   defer lookup.deinit();
// with:
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();
    }

    // stdin is an std.io.Reader
    // the opposite of an std.io.Writer, which we already saw
    const stdin = std.io.getStdIn().reader();

    // stdout is an 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) {
                // In Windows lines are terminated by \r\n.
                // We need to strip out the \r
                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 в первой главе. Такой "тип" это весьма полезная форма утиной (неявной) типизации во время компиляции. Вот простой логгер:

pub const Logger = struct {
    level: Level,

    // "error" is reserved, names inside an @"..." are always
    // treated as identifiers
    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, которую мы уже несколько раз использовали:

// I **hate** multi-line function definitions
// But I'll make an exception for a guide which
// you might be reading on a small screen.

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, вот его содержимое:

// Oh, a hidden lesson, look at the type of b
// and the return type!!

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 {
    // By default, only tests in the specified file
    // are included. This magic line of code will
    // cause a reference to all nested containers
    // to be tested.
    @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 в качестве зависимости, для чего нужно 3 дополнения. Сначала создадим модуль, указывающий на calc:

// You can put this near the top of the build
// function, before the call to 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" },
});
// add this
exe.addModule("calc", calc_module);
b.installArtifact(exe);

....

const tests = b.addTest(.{
    .target = target,
    .optimize = optimize,
    .root_source_file = .{ .path = "learning.zig" },
});
// add this
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) сложнее: вроде как на данный момент нет нормального способа узнать, чему должно быть равно это поле, поэтому пока оставим эту заглушку.

Чтобы использовать обозначенную зависимость, в файле build.zig нужно сделать одно изменение:

// это убрать
const calc_module = b.addModule("calc", .{
    .source_file = .{ .path = "calc/calc.zig" },
});

// и заменить на это
const calc_dep = b.dependency("calc", .{.target = target,.optimize = optimize});
const calc_module = calc_dep.module("calc");

Если вы сейчас попробуете запустить zig build test, то получите ошибку:

build.zig.zon:9:15: error: hash mismatch:
expected: 12ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff,
found: 122053da05e0c9348d91218ef015c8307749ef39f8e90c208a186e5f444e818672d4

Скопируйте правильное значение хэша в build.zig.zon и снова попробуйте zig build test. Теперь всё должно работать.

Это выглядит ужасно, но, будем надеяться, ситуация исправится. По большому счёту, вы всегда можете скопировать нужное значение этого хэша из других проектов и после этого можно продолжать.

Небольшое предупреждение: Zig иногда может не замечать изменения в зависимостях. Если вы пытаетесь обновить зависимость, но Zig как будто бы этого не хочет замечать... что ж, просто сотрите каталог zig-cache в проекте, а также ~/.cache/zig.