Раздел 32 · Системное программирование: Zig, ассемблер, Verilog

comptime, сборка, тесты и C

middle-senior~110 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

comptime, сборка, тесты и C

Zig умеет исполнять сам себя во время компиляции. Из этого одного свойства вырастают дженерики, рефлексия и статические проверки без макросов и без отдельного языка шаблонов. Вторая половина урока про инструменты вокруг компилятора: встроенные тесты, build.zig, кросс-компиляция C одним бинарником и вызовы в обе стороны между Zig и C. В конце разберём, как устроен раннер курса и как повторить его проверку у себя.

Цели урока

  • Понять, что comptime это не аннотация, а режим исполнения обычного Zig внутри компилятора.
  • Написать дженерик как функцию, которая принимает тип и возвращает тип, и увидеть, что одинаковые вызовы дают один и тот же тип.
  • Освоить @typeInfo, @hasField, @hasDecl, @Int и @Enum как инструменты рефлексии, а inline for как цикл, который разворачивается до кодогенерации.
  • Использовать @compileError как проверку контракта, которая срабатывает раньше первого запуска.
  • Гонять тесты через zig test, фильтровать их, ловить утечки, подключать тесты вложенных файлов.
  • Собрать маленький проект через build.zig с модулем и шагами run и test.
  • Собрать C-программу под чужую архитектуру одной командой zig cc и убедиться по байтам, что получился нужный ELF.
  • Подключить C-заголовок через addTranslateC и вызвать Zig-функцию из C через export.
  • Знать, что именно запускает песочница курса и как получить тот же результат локально.

Идея: компилятор исполняет твой код

В прошлых уроках ты уже встречал слово comptime: в уроке про значения и типы цикл по кортежу типов был помечен inline for, а в уроке про аллокаторы std.ArrayList(u8) выглядел как вызов функции. Пора объяснить, что за этим стоит.

Представь таблицу Фибоначчи, которую ты посчитал на калькуляторе и вписал в код числами, чтобы программа не считала её при каждом запуске. Zig делает то же сам: внутри компилятора живёт интерпретатор языка. Любое выражение, чьи входы известны на этапе компиляции, компилятор может вычислить сам, а если стоит слово comptime, то обязан. comptime не отдельный подъязык с ограниченным синтаксисом, как шаблоны C++ или макросы: это обычные функции, циклы и ветвления, только выполненные заранее.

Три формы, в которых comptime встречается чаще всего: параметр функции, блок и переменная.

const std = @import("std");

// comptime-параметр: значение обязано быть известно на этапе компиляции.
fn repeat(comptime n: usize, ch: u8) [n]u8 {
    var out: [n]u8 = undefined;
    for (&out) |*slot| slot.* = ch;
    return out;
}

// Обычная функция, но вызвать её можно и в comptime-контексте.
fn fib(n: u32) u64 {
    var a: u64 = 0;
    var b: u64 = 1;
    var i: u32 = 0;
    while (i < n) : (i += 1) {
        const next = a + b;
        a = b;
        b = next;
    }
    return a;
}

// Таблица посчитана компилятором и лежит в .rodata бинарника.
const fib_table = blk: {
    var table: [20]u64 = undefined;
    for (&table, 0..) |*slot, i| slot.* = fib(i);
    break :blk table;
};

test "comptime-параметр задаёт длину массива в типе" {
    const dashes = repeat(4, '-');
    try std.testing.expectEqual([4]u8, @TypeOf(dashes));
    try std.testing.expectEqualStrings("----", &dashes);
}

test "comptime-блок и comptime var" {
    // Всё внутри comptime { } выполняется интерпретатором компилятора.
    comptime {
        var total: u64 = 0;
        for (fib_table) |v| total += v;
        // Провал этого assert это ошибка компиляции, а не падение в рантайме.
        if (total != 10945) @compileError("сумма первых 20 чисел Фибоначчи должна быть 10945");
    }
    try std.testing.expectEqual(@as(u64, 4181), fib_table[19]);
    // Тот же fib, но вызванный в рантайме: аргумент известен только сейчас.
    var n: u32 = 10;
    _ = &n;
    try std.testing.expectEqual(@as(u64, 55), fib(n));
}

test "comptime-выражение принудительно" {
    // comptime перед выражением: результат обязан быть константой.
    const at_compile = comptime fib(50);
    try std.testing.expectEqual(@as(u64, 12586269025), at_compile);
}
$ zig test comptime_basics.zig
1/3 comptime_basics.test.comptime-параметр задаёт длину массива в типе...OK
2/3 comptime_basics.test.comptime-блок и comptime var...OK
3/3 comptime_basics.test.comptime-выражение принудительно...OK
All 3 tests passed.

Разбери четыре места.

  1. Параметр comptime n: usize в repeat нужен потому, что n попадает в тип возвращаемого значения [n]u8, а тип обязан быть известен до кодогенерации.
  2. Функция fib ничем не помечена, и тем не менее fib_table заполняется на этапе компиляции: инициализатор глобальной константы это comptime-контекст, и любая функция, которую там вызвали, исполняется интерпретатором.
  3. Внутри fib_table живёт обычная var table, это и есть comptime var: переменная, которая существует только во время компиляции.
  4. comptime fib(50) заставляет вычислить значение заранее там, где компилятор мог бы и отложить.

Ограничение одно: в comptime нельзя то, что зависит от машины и внешнего мира. Ни системных вызовов, ни указателей на рантайм-память, ни чтения файлов. Всё остальное, включая аллокацию массивов, циклы и рекурсию, работает.

Функция, возвращающая тип

Раз в comptime можно вычислить число, можно вычислить и тип: type в Zig такой же comptime-тип, как comptime_int. Дженерик это функция с параметром comptime T: type, которая возвращает struct { ... }.

const std = @import("std");

/// Функция над типами: принимает тип и ёмкость, возвращает новый тип.
pub fn FixedStack(comptime T: type, comptime capacity: usize) type {
    return struct {
        const Self = @This();

        items: [capacity]T = undefined,
        len: usize = 0,

        pub const empty: Self = .{};

        pub fn push(self: *Self, value: T) error{Full}!void {
            if (self.len == capacity) return error.Full;
            self.items[self.len] = value;
            self.len += 1;
        }

        pub fn pop(self: *Self) ?T {
            if (self.len == 0) return null;
            self.len -= 1;
            return self.items[self.len];
        }

        pub fn peek(self: Self) ?T {
            return if (self.len == 0) null else self.items[self.len - 1];
        }
    };
}

test "стек байтов" {
    var s: FixedStack(u8, 2) = .empty;
    try s.push('a');
    try s.push('b');
    try std.testing.expectError(error.Full, s.push('c'));
    try std.testing.expectEqual(@as(?u8, 'b'), s.pop());
    try std.testing.expectEqual(@as(?u8, 'a'), s.peek());
}

test "стек срезов: тот же код, другой T" {
    var s: FixedStack([]const u8, 4) = .empty;
    try s.push("hello");
    try s.push("zig");
    try std.testing.expectEqualStrings("zig", s.pop().?);
    try std.testing.expectEqualStrings("hello", s.pop().?);
    try std.testing.expectEqual(@as(?[]const u8, null), s.pop());
}

test "типы мемоизируются: одинаковые аргументы дают один тип" {
    try std.testing.expect(FixedStack(u8, 2) == FixedStack(u8, 2));
    try std.testing.expect(FixedStack(u8, 2) != FixedStack(u8, 3));
    try std.testing.expect(FixedStack(u8, 2) != FixedStack(i8, 2));
    try std.testing.expect(std.mem.endsWith(u8, @typeName(FixedStack(u8, 2)), "FixedStack(u8,2)"));

    // Значение одного экземпляра типа можно положить в переменную другого.
    var a: FixedStack(u8, 2) = .empty;
    try a.push(7);
    const b: FixedStack(u8, 2) = a;
    try std.testing.expectEqual(@as(?u8, 7), b.peek());
}
$ zig test fixed_stack.zig
1/3 fixed_stack.test.стек байтов...OK
2/3 fixed_stack.test.стек срезов: тот же код, другой T...OK
3/3 fixed_stack.test.типы мемоизируются: одинаковые аргументы дают один тип...OK
All 3 tests passed.

Внутри struct слово @This() даёт ссылку на тип, который сейчас объявляется: без него методы не могли бы назвать свой тип, ведь у него ещё нет имени.

Обрати внимание на третий тест. Компилятор мемоизирует вызовы функций над типами: FixedStack(u8, 2) в двух разных местах программы это ровно один тип, поэтому значения между ними присваиваются без преобразований, а == на типах возвращает true. Если бы каждая инстанциация создавала новый тип, дженерики были бы бесполезны: срез из одной функции нельзя было бы передать в другую. Имя типа собирается из имени файла, имени функции и аргументов, что видно в @typeName: оно заканчивается на FixedStack(u8,2).

Сравни с дженериками Rust: там ограничение на T объявляется трейтом в сигнатуре, здесь ограничений в сигнатуре нет вовсе. Тело использует T как хочет, и если T не поддерживает нужную операцию, ошибка всплывёт при инстанциации. Это называют утиной типизацией на этапе компиляции: проверка есть, но она происходит на первом использовании, а не при объявлении. Как сделать проверку явной, увидим через два раздела.

Рефлексия: @typeInfo и его друзья

Как напечатать любую структуру как name=value, не зная заранее её полей? Нужно спросить у компилятора, что это за тип. @typeInfo(T) возвращает описание типа как значение std.builtin.Type, объединения с тегом. Для целого там знаковость и ширина, для структуры список полей и объявлений, для указателя размер и константность. Поскольку это обычное значение, по нему можно делать switch, читать поля и строить на его основе новые типы.

const std = @import("std");

const Header = struct {
    magic: u32,
    version: u16,
    flags: u8,

    pub const size_on_wire = 7;
};

test "@typeInfo: целые и числа с плавающей точкой" {
    const int_info = @typeInfo(u12);
    try std.testing.expectEqual(std.builtin.Signedness.unsigned, int_info.int.signedness);
    try std.testing.expectEqual(@as(u16, 12), int_info.int.bits);

    const float_info = @typeInfo(f32);
    try std.testing.expectEqual(@as(u16, 32), float_info.float.bits);

    // Тег объединения тоже доступен: удобно в switch.
    const kind: std.builtin.Type = @typeInfo(bool);
    try std.testing.expect(kind == .bool);
}

test "@typeInfo: структура и её поля" {
    const info = @typeInfo(Header).@"struct";
    try std.testing.expectEqual(std.builtin.Type.ContainerLayout.auto, info.layout);
    try std.testing.expectEqual(@as(usize, 3), info.fields.len);
    try std.testing.expectEqualStrings("magic", info.fields[0].name);
    try std.testing.expectEqual(u32, info.fields[0].type);
    try std.testing.expectEqual(@as(usize, 1), info.decls.len);
    try std.testing.expectEqualStrings("size_on_wire", info.decls[0].name);
}

test "@hasField и @hasDecl" {
    try std.testing.expect(@hasField(Header, "flags"));
    try std.testing.expect(!@hasField(Header, "checksum"));
    try std.testing.expect(@hasDecl(Header, "size_on_wire"));
    try std.testing.expect(!@hasDecl(Header, "flags"));
}

test "@Int и @Enum собирают тип из описания" {
    // Целое из знаковости и ширины: так std.math.IntFittingRange подбирает тип под диапазон.
    const I24 = @Int(.signed, 24);
    try std.testing.expectEqual(i24, I24);
    try std.testing.expectEqual(@as(i24, -8388608), std.math.minInt(I24));

    // Перечисление из списка имён: тег u8, значения 0, 1, 2.
    const Level = @Enum(u8, .exhaustive, &.{ "debug", "info", "err" }, &.{ 0, 1, 2 });
    try std.testing.expectEqual(@as(u8, 1), @intFromEnum(Level.info));
    try std.testing.expectEqualStrings("err", @tagName(@as(Level, @enumFromInt(2))));
}

Поле объединения называется @"struct", потому что struct это ключевое слово, а синтаксис @"..." позволяет использовать любое слово как идентификатор. Разница между @hasField и @hasDecl та же, что между полем экземпляра и объявлением в пространстве имён: flags есть у каждого значения Header, а size_on_wire это константа, привязанная к типу.

Обратная операция, построение типа из описания, в Zig 0.16 разложена по отдельным встроенным функциям: @Int(signedness, bits), @Enum(...), @Struct(...), @Pointer(...). Старый универсальный @Type с одним большим объединением убран. Чаще всего тебе понадобится @Int: так стандартная библиотека подбирает целое под диапазон, а в сетевых форматах так объявляют поле ровно в столько бит, сколько в спецификации.

inline for: цикл, которого нет

Список полей из @typeInfo это comptime-значение, и обойти его обычным for нельзя: у каждого поля своё имя и свой тип, а тело цикла в рантайме должно быть одним и тем же кодом для всех итераций. inline for решает это раскруткой: компилятор копирует тело цикла для каждого элемента и подставляет в копию конкретное поле. Итерации становятся разными кусками кода, и в каждом имя поля и его тип известны.

const std = @import("std");

const Header = struct {
    magic: u32,
    version: u16,
    flags: u8,
};

/// Сумма всех полей структуры, сколько бы их ни было и какой бы ширины они ни были.
fn sumFields(value: anytype) i64 {
    const T = @TypeOf(value);
    var total: i64 = 0;
    inline for (@typeInfo(T).@"struct".fields) |field| {
        // field.name это comptime-строка, поэтому @field работает.
        total += @field(value, field.name);
    }
    return total;
}

/// Подпись структуры: имя поля и его тип через запятую.
fn describe(comptime T: type) []const u8 {
    comptime var out: []const u8 = "";
    inline for (@typeInfo(T).@"struct".fields, 0..) |field, i| {
        const sep = if (i == 0) "" else ", ";
        out = out ++ sep ++ field.name ++ ": " ++ @typeName(field.type);
    }
    return out;
}

/// inline while: тот же приём для счётчика, известного на этапе компиляции.
fn powersOfTwo(comptime count: usize) [count]u32 {
    var out: [count]u32 = undefined;
    comptime var i = 0;
    inline while (i < count) : (i += 1) {
        out[i] = 1 << i;
    }
    return out;
}

test "inline for по полям" {
    const h: Header = .{ .magic = 0xCAFE, .version = 3, .flags = 1 };
    try std.testing.expectEqual(@as(i64, 0xCAFE + 3 + 1), sumFields(h));
    try std.testing.expectEqualStrings("magic: u32, version: u16, flags: u8", describe(Header));
}

test "inline while" {
    try std.testing.expectEqual([5]u32{ 1, 2, 4, 8, 16 }, powersOfTwo(5));
}

@field(value, "name") обращается к полю по строке, известной на этапе компиляции, и после раскрутки превращается в обычное value.magic. В describe строка собирается оператором ++, который для comptime-срезов конкатенирует их в новый массив: результат живёт в rodata и не стоит ничего в рантайме. inline while работает так же, только счётчик обязан быть comptime var.

Виджет ниже показывает, во что превращается sumFields. Выбери структуру, кликни по полю, переключи вид на ассемблер. Ассемблер настоящий: снят с этого кода командой zig build-obj -O ReleaseFast -target x86_64-linux -femit-asm=out.s -fno-emit-bin под Zig 0.16.0. Раскрой блок про обычный for по срезу: там тот же оптимизатор, но цикл остался циклом, потому что длина известна только в рантайме.

inline for · до и после раскрутки

Исходник

кликни по полю структуры
            const Vec3 = struct {};
          
fn sumFields(value: anytype) i64 {    var total: i64 = 0;    inline for (@typeInfo(@TypeOf(value)).@"struct".fields) |field| {        total += @field(value, field.name);    }    return total;}

После comptime

              
                  fn sumFields(value: Vec3) i64 {
                
                      var total: i64 = 0;
                
                      total += value.x;
                
                      total += value.y;
                
                      total += value.z;
                
                      return total;
                
                  }
                
            

Три поля по четыре байта: три загрузки со знаковым расширением до 64 бит и два сложения. Ни счётчика, ни перехода.

Для сравнения: обычный for по срезу, длина известна только в рантайме
fn sumSlice(values: []const i64) i64 {    var total: i64 = 0;    for (values) |v| total += v;    return total;}
sum_slice:    test    rsi, rsi    je      .LBB0_1            ; len == 0    mov     ecx, esi    and     ecx, 7             ; хвост: len % 8    cmp     rsi, 8    jae     .LBB0_4    xor     eax, eax    xor     edx, edx    jmp     .LBB0_6.LBB0_4:    and     rsi, -8    xor     eax, eax    xor     edx, edx.LBB0_5:                        ; тело развёрнуто LLVM по 8    add     rax, qword ptr [rdi + 8*rdx]    add     rax, qword ptr [rdi + 8*rdx + 8]    ...    add     rax, qword ptr [rdi + 8*rdx + 56]    add     rdx, 8    cmp     rsi, rdx    jne     .LBB0_5.LBB0_6:    test    rcx, rcx    je      .LBB0_9    lea     rdx, [rdi + 8*rdx]    xor     esi, esi.LBB0_8:                        ; хвостовой цикл по одному    add     rax, qword ptr [rdx + 8*rsi]    add     rsi, 1    cmp     rcx, rsi    jne     .LBB0_8.LBB0_9:    ret

Здесь цикл остаётся циклом: счётчик в rdx, сравнение, переход назад. LLVM развернул тело по восемь и добавил хвостовой цикл, но число итераций он не знает, поэтому убрать цикл целиком не может. В версии с inline for цикла не было уже на выходе из фронтенда: comptime подставил имена полей, LLVM получил прямую линию сложений.

Важно не перепутать причину и следствие. inline for не про скорость: LLVM и сам развернул бы маленький цикл по массиву известной длины. inline for про то, что тело цикла может зависеть от типа элемента, и это единственный способ обойти поля структуры. Оборотная сторона: каждая итерация это отдельная копия кода, и inline for по сотне полей даст сто копий тела. Там, где хватает обычного for, пиши обычный for.

@compileError как проверка контракта

Утиная типизация дженериков даёт ошибки глубоко внутри тела: ты вызываешь sum(bool, ...), а компилятор жалуется на + в строке, которую ты не писал. @compileError позволяет проверить контракт на входе и объяснить его словами.

const std = @import("std");

/// Сумма среза чисел. Только целые и числа с плавающей точкой,
/// остальное отвергается на этапе компиляции.
pub fn sum(comptime T: type, values: []const T) T {
    switch (@typeInfo(T)) {
        .int, .float => {},
        else => @compileError("sum ждёт целое или float, а получил " ++ @typeName(T)),
    }
    var total: T = 0;
    for (values) |v| total += v;
    return total;
}

test "sum для целых и float" {
    try std.testing.expectEqual(@as(u8, 6), sum(u8, &.{ 1, 2, 3 }));
    try std.testing.expectEqual(@as(f64, 1.5), sum(f64, &.{ 0.5, 1.0 }));
}

Второй файл импортирует первый и пробует сложить булевы значения:

const std = @import("std");
const lib = @import("compile_error.zig");

test "sum для bool не компилируется" {
    _ = lib.sum(bool, &.{ true, false });
}
$ zig test compile_error_bad.zig
compile_error.zig:8:17: error: sum ждёт целое или float, а получил bool
        else => @compileError("sum ждёт целое или float, а получил " ++ @typeName(T)),
                ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
referenced by:
    test.sum для bool не компилируется: compile_error_bad.zig:5:16

Заметь, что switch с пустой веткой .int, .float => {} это не рантайм-код: @typeInfo(T) известно на этапе компиляции, поэтому весь switch вычисляется заранее, и в бинарник попадает либо пустота, либо ошибка. Так в Zig пишут то, для чего в Rust нужен трейт-баунд, а в C++ concept: проверка свойств типа на входе в функцию, только инструментом служит обычный switch.

anytype против comptime T: type

Есть два способа сделать функцию обобщённой, и sumFields выше использовал второй. comptime T: type называет тип явно: сигнатура читается без тела, в вызове тип виден глазами, sum(u8, ...). anytype выводит тип из аргумента: короче в вызове, но сигнатура ничего не обещает, и читателю приходится открывать тело.

const std = @import("std");

// anytype: тип выводится из аргумента, сигнатура ничего не обещает.
fn maxAny(a: anytype, b: @TypeOf(a)) @TypeOf(a) {
    return if (a > b) a else b;
}

// comptime T: тип назван явно, сигнатура читается без тела.
fn maxOf(comptime T: type, a: T, b: T) T {
    return if (a > b) a else b;
}

// Разный код для разных типов: comptime-ветвление внутри одной функции.
fn describe(value: anytype) []const u8 {
    return switch (@typeInfo(@TypeOf(value))) {
        .int => |info| if (info.signedness == .signed) "signed int" else "unsigned int",
        .float => "float",
        .pointer => |info| if (info.size == .slice) "slice" else "pointer",
        .@"struct" => "struct",
        else => "something else",
    };
}

test "anytype и comptime T дают один результат" {
    try std.testing.expectEqual(@as(u32, 9), maxAny(@as(u32, 9), 4));
    try std.testing.expectEqual(@as(u32, 9), maxOf(u32, 9, 4));
    // comptime_int тоже тип: литералы без @as складываются как comptime-числа.
    try std.testing.expectEqual(comptime_int, @TypeOf(maxAny(9, 4)));
}

test "ветвление по типу аргумента" {
    try std.testing.expectEqualStrings("signed int", describe(@as(i8, -1)));
    try std.testing.expectEqualStrings("unsigned int", describe(@as(u64, 1)));
    try std.testing.expectEqualStrings("float", describe(@as(f32, 1.0)));
    try std.testing.expectEqualStrings("slice", describe(@as([]const u8, "hi")));
    try std.testing.expectEqualStrings("struct", describe(.{ .x = 1 }));
}

Правило, которого придерживается стандартная библиотека: anytype там, где аргумент почти всегда литерал или структура на месте (std.debug.print и его кортеж аргументов, expectEqual), и comptime T: type там, где тип определяет поведение и его полезно видеть в вызове (std.ArrayList(T), std.mem.eql(u8, ...)). Заметь ещё одну деталь из первого теста: maxAny(9, 4) без @as возвращает comptime_int, потому что оба литерала comptime-числа, и вся функция целиком вычислена интерпретатором.

zig test: тесты внутри файла

Ты весь урок читаешь блоки test "...". Это часть языка, а не библиотека: zig test file.zig собирает все тесты, до которых дотянулся семантический анализ из этого файла, линкует их со стандартным раннером и запускает. Модуль std.testing даёт проверки: expect, expectEqual, expectEqualStrings, expectEqualSlices, expectError, а std.testing.allocator это DebugAllocator, который после каждого теста проверяет, что всё выделенное освобождено.

Есть одна неочевидность, на которую натыкается каждый. Тесты из вложенного файла попадают в бинарник только если этот файл был проанализирован, а анализ ленивый: @import("math.zig") сам по себе ничего не анализирует, пока из него что-нибудь не использовали. Для гарантии в корневом файле пишут пустой тест с _ = math.

const std = @import("std");

pub fn clampU8(value: i32) u8 {
    return @intCast(std.math.clamp(value, 0, 255));
}

pub fn parseDigits(text: []const u8) error{NotADigit}!u32 {
    var out: u32 = 0;
    for (text) |ch| {
        if (ch < '0' or ch > '9') return error.NotADigit;
        out = out * 10 + (ch - '0');
    }
    return out;
}

test "clampU8 режет по краям" {
    try std.testing.expectEqual(@as(u8, 0), clampU8(-5));
    try std.testing.expectEqual(@as(u8, 255), clampU8(1000));
    try std.testing.expectEqual(@as(u8, 42), clampU8(42));
}

test "parseDigits: цифры и ошибка" {
    try std.testing.expectEqual(@as(u32, 123), try parseDigits("123"));
    try std.testing.expectError(error.NotADigit, parseDigits("12a"));
}

Корневой файл root.zig рядом с ним:

const std = @import("std");
const math = @import("math.zig");

pub fn joinDigits(allocator: std.mem.Allocator, parts: []const u32) ![]u8 {
    var out: std.ArrayList(u8) = .empty;
    errdefer out.deinit(allocator);
    for (parts, 0..) |part, i| {
        if (i != 0) try out.append(allocator, '-');
        try out.print(allocator, "{d}", .{part});
    }
    return out.toOwnedSlice(allocator);
}

test "joinDigits собирает строку" {
    const joined = try joinDigits(std.testing.allocator, &.{ 1, 22, 333 });
    defer std.testing.allocator.free(joined);
    try std.testing.expectEqualStrings("1-22-333", joined);
}

test "срезы сравниваются поэлементно" {
    const a = [_]u8{ 1, 2, 3 };
    try std.testing.expectEqualSlices(u8, &.{ 1, 2, 3 }, &a);
}

// Без этой строки тесты из math.zig не попадут в бинарник:
// zig test собирает только то, до чего дотянулся анализ из корневого файла.
test {
    _ = math;
}
$ zig test root.zig
1/5 root.test.joinDigits собирает строку...OK
2/5 root.test.срезы сравниваются поэлементно...OK
3/5 root.test_0...OK
4/5 math.test.clampU8 режет по краям...OK
5/5 math.test.parseDigits: цифры и ошибка...OK
All 5 tests passed.

$ zig test root.zig --test-filter "parseDigits"
1/2 root.test_0...OK
2/2 math.test.parseDigits: цифры и ошибка...OK
All 2 tests passed.

Безымянный тест получает имя test_0 и попадает в каждый прогон, это нормально. Флаг --test-filter оставляет тесты, в имени которых есть подстрока, пригодится, когда отлаживаешь один случай. Если убрать блок с _ = math, запуск покажет два теста вместо пяти, и никто не предупредит, что три пропали.

Есть у тестового блока и второе применение, о котором редко пишут: это черновик. Файлу с тестами не нужна main, чтобы его запустить, а std.debug.print внутри теста печатает в stderr прямо между строками отчёта. Когда хочешь узнать, что вернёт std.fmt.parseInt на строке с плюсом или в какие байты ляжет packed struct, не заводи отдельную программу: напиши test "черновик" с печатью, запусти zig test, посмотри и удали. В исполняемый файл тесты не попадают, так что забытый черновик стоит только секунды на сборке.

Так выглядит провал. Первый тест забыл free, второй ждёт не то число:

const std = @import("std");

test "утечка: аллокатор тестов её поймает" {
    const buf = try std.testing.allocator.alloc(u8, 16);
    _ = buf; // забыли free
}

test "падение: ожидание не совпало" {
    try std.testing.expectEqual(@as(u32, 4), 2 + 3);
}
$ zig test leak.zig
1/2 leak.test.утечка: аллокатор тестов её поймает...OK
[DebugAllocator] (err): memory address 0x102280000 leaked:
leak.zig:4:48: 0x10212df47 in test.утечка: аллокатор тестов её поймает (test)
    const buf = try std.testing.allocator.alloc(u8, 16);
                                               ^
2/2 leak.test.падение: ожидание не совпало...expected 4, found 5
FAIL (TestExpectedEqual)
leak.zig:9:5: 0x10212d96f in test.падение: ожидание не совпало (test)
    try std.testing.expectEqual(@as(u32, 4), 2 + 3);
    ^
1 passed; 0 skipped; 1 failed.
1 errors were logged.
1 tests leaked memory.
error: the following test command failed with exit code 1:
~/.cache/zig/o/118dc48c34777aab7e051c686994df1f/test --seed=0x76dec581

Обрати внимание: тест с утечкой сначала пишет OK, и только потом аллокатор печатает адрес и стек выделения. Итоговая строка 1 tests leaked memory и ненулевой код выхода делают такой прогон проваленным. Раннер курса это тоже учитывает, к нему вернёмся в конце.

build.zig: проект вместо одного файла

Пока проект помещается в один файл, хватает zig run и zig test. Как только появляются модули, C-зависимости или несколько целей, нужна система сборки, и в Zig она написана на Zig: файл build.zig экспортирует функцию build, которая описывает граф шагов, а команда zig build этот граф исполняет. Никакого отдельного языка вроде Makefile или CMake.

Проект из трёх файлов: библиотека geometry, программа area, которая её использует, и два шага.

buildproj/
├── build.zig
└── src/
    ├── geometry.zig
    └── main.zig
const std = @import("std");

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

    // Модуль с библиотечным кодом: его можно отдать другим пакетам и подключить в тесты.
    const geometry = b.addModule("geometry", .{
        .root_source_file = b.path("src/geometry.zig"),
        .target = target,
        .optimize = optimize,
    });

    const exe = b.addExecutable(.{
        .name = "area",
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/main.zig"),
            .target = target,
            .optimize = optimize,
            .imports = &.{.{ .name = "geometry", .module = geometry }},
        }),
    });
    b.installArtifact(exe);

    // Шаг run: zig build run -- 3 4
    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| run_cmd.addArgs(args);
    const run_step = b.step("run", "Собрать и запустить area");
    run_step.dependOn(&run_cmd.step);

    // Шаг test: тесты модуля geometry и тесты main.zig.
    const geometry_tests = b.addTest(.{ .root_module = geometry });
    const main_tests = b.addTest(.{ .root_module = exe.root_module });
    const test_step = b.step("test", "Прогнать тесты");
    test_step.dependOn(&b.addRunArtifact(geometry_tests).step);
    test_step.dependOn(&b.addRunArtifact(main_tests).step);
}
const std = @import("std");

pub const Rect = struct {
    w: u32,
    h: u32,

    pub fn area(self: Rect) u64 {
        return @as(u64, self.w) * self.h;
    }
};

test "площадь прямоугольника" {
    const r: Rect = .{ .w = 3, .h = 4 };
    try std.testing.expectEqual(@as(u64, 12), r.area());
}
const std = @import("std");
const geometry = @import("geometry");

fn parseArg(text: []const u8) !u32 {
    return std.fmt.parseInt(u32, text, 10);
}

pub fn main(init: std.process.Init) !void {
    const args = try init.minimal.args.toSlice(init.arena.allocator());
    if (args.len != 3) {
        std.debug.print("usage: area <w> <h>\n", .{});
        return error.BadUsage;
    }
    const rect: geometry.Rect = .{ .w = try parseArg(args[1]), .h = try parseArg(args[2]) };

    var buf: [64]u8 = undefined;
    var w = std.Io.File.stdout().writer(init.io, &buf);
    try w.interface.print("area = {d}\n", .{rect.area()});
    try w.interface.flush();
}

test "parseArg" {
    try std.testing.expectEqual(@as(u32, 42), try parseArg("42"));
    try std.testing.expectError(error.InvalidCharacter, parseArg("4x"));
}

Ключевые вызовы.

  • b.addModule объявляет именованный модуль, который в коде подключается как @import("geometry"), без пути и расширения: модули это единица, которую пакетный менеджер отдаёт наружу.
  • b.createModule делает то же самое для корня программы, а список imports говорит, какие модули этому корню видны.
  • В Zig 0.16 у addExecutable и addTest один обязательный параметр root_module, и цель с уровнем оптимизации живут внутри модуля, а не у артефакта.
  • standardTargetOptions и standardOptimizeOption добавляют флаги -Dtarget и -Doptimize в командную строку.
  • Аргументы командной строки в 0.16 приходят через init.minimal.args, а память под них бери из init.arena: она живёт до конца процесса и освобождается автоматически.
$ zig build --help
Usage: zig build [steps] [options]

Steps:
  install (default)            Copy build artifacts to prefix path
  uninstall                    Remove build artifacts from prefix path
  run                          Собрать и запустить area
  test                         Прогнать тесты

$ zig build
$ ./zig-out/bin/area 3 4
area = 12

$ zig build run -- 7 6
area = 42

$ zig build test --summary all
Build Summary: 5/5 steps succeeded; 2/2 tests passed
test success
+- run test 1 pass (1 total) 3ms MaxRSS:2M
|  +- compile test Debug native cached 39ms MaxRSS:35M
+- run test 1 pass (1 total) 3ms MaxRSS:2M
   +- compile test Debug native cached 39ms MaxRSS:35M

Шаг install кладёт бинарник в zig-out/bin, zig build run -- 7 6 передаёт всё после -- программе. Промежуточные артефакты живут в .zig-cache рядом с проектом; повторный zig build test без изменений видит cached и не компилирует заново.

zig cc: кросс-компилятор C в комплекте

В первом уроке было обещание: бинарник zig умеет собирать C для любой платформы. Внутри него живёт clang плюс исходники musl, glibc и других libc, которые компилируются под нужную цель при первом обращении. Команда zig cc принимает флаги clang, и главный из них -target.

#include <stdio.h>

int main(void) {
    printf("hello from C, compiled by zig cc\n");
    return 0;
}
$ zig cc -O2 -s -o hello-native hello.c
$ ./hello-native
hello from C, compiled by zig cc

$ zig cc -target x86_64-linux-musl -O2 -s -o hello-x86 hello.c
$ zig cc -target aarch64-linux-musl -O2 -s -o hello-arm hello.c
$ ls -l hello-x86 hello-arm
5224 hello-x86
5984 hello-arm

Всё это на ноутбуке с Apple Silicon, без единого установленного тулчейна кроме zig. Два бинарника под Linux запустить здесь нельзя, а проверить, что они те, за кого себя выдают, можно по первым байтам. Формат ELF начинается с фиксированного заголовка, и архитектура записана в нём на смещении 18:

$ head -c 20 hello-x86 | xxd
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............
00000010: 0200 3e00                                ..>.

$ head -c 20 hello-arm | xxd
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............
00000010: 0200 b700                                ....

$ head -c 4 hello-native | xxd
00000000: cffa edfe                                ....

$ ./hello-x86
zsh: exec format error: ./hello-x86

Первые четыре байта 7f 45 4c 46 это \x7fELF. Пятый байт 02 означает 64 бита, шестой 01 порядок little-endian. На смещении 16 лежит тип файла: 0200 это исполняемый файл. На смещении 18 архитектура: 3e00 это 0x3e, x86-64, а b700 это 0xb7, AArch64. Родной бинарник macOS начинается с cf fa ed fe, магии Mach-O, и это другой формат целиком. Так ты можешь проверить любой файл, не имея под рукой утилиты file. Ошибка exec format error при попытке запустить x86-64 ELF на macOS это ядро, которое прочитало те же байты и не узнало формат.

Ключ -s убирает таблицу символов, -O2 включает оптимизации: без них бинарник со статически влинкованным musl и отладочной информацией весит 1,6 МБ вместо пяти килобайт. Триплет цели устроен как arch-os-abi: x86_64-linux-musl статически линкует musl, x86_64-linux-gnu даёт динамическую линковку с glibc нужной версии, полный список печатает zig targets. Раннер курса собирает всё под x86_64-linux-musl по двум причинам: статический бинарник не зависит от libc внутри контейнера, а ассемблер, который ты увидишь в следующих блоках, совпадает с уроками байт в байт.

C из Zig: addTranslateC

Раз компилятор умеет C, он умеет и читать заголовки. Инструмент translate-c превращает .h в файл Zig с объявлениями extern fn, константами из #define и структурами, а build.zig подключает результат как обычный модуль. Старый путь @cImport прямо в исходнике в 0.16 ещё компилируется, но это наследие: он смешивал сборку с кодом, а addTranslateC держит их порознь, и новый код пишут через него.

Маленькая C-библиотека с одной функцией и одной константой:

#include <stddef.h>
#include <stdint.h>

#define CHECKSUM_SEED 0x1505u

uint32_t checksum(const uint8_t *data, size_t len);
#include "checksum.h"

/* djb2: hash = hash * 33 + byte */
uint32_t checksum(const uint8_t *data, size_t len) {
    uint32_t hash = CHECKSUM_SEED;
    for (size_t i = 0; i < len; i++) {
        hash = hash * 33u + data[i];
    }
    return hash;
}
const std = @import("std");

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

    // translate-c превращает заголовок в Zig-модуль с объявлениями.
    const translate = b.addTranslateC(.{
        .root_source_file = b.path("c/checksum.h"),
        .target = target,
        .optimize = optimize,
    });
    const c_api = translate.createModule();

    const root = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
        .link_libc = true,
        .imports = &.{.{ .name = "c", .module = c_api }},
    });
    // Сама реализация компилируется как C и линкуется в тот же бинарник.
    root.addCSourceFile(.{ .file = b.path("c/checksum.c"), .flags = &.{"-std=c11"} });
    root.addIncludePath(b.path("c"));

    const exe = b.addExecutable(.{ .name = "sum", .root_module = root });
    b.installArtifact(exe);

    const run_step = b.step("run", "Запустить sum");
    run_step.dependOn(&b.addRunArtifact(exe).step);

    const tests = b.addTest(.{ .root_module = root });
    const test_step = b.step("test", "Тесты поверх C-функции");
    test_step.dependOn(&b.addRunArtifact(tests).step);
}
const std = @import("std");
const c = @import("c");

fn checksumOf(text: []const u8) u32 {
    return c.checksum(text.ptr, text.len);
}

pub fn main(init: std.process.Init) !void {
    var buf: [64]u8 = undefined;
    var w = std.Io.File.stdout().writer(init.io, &buf);
    try w.interface.print("seed = 0x{x}, checksum(\"zig\") = {d}\n", .{ c.CHECKSUM_SEED, checksumOf("zig") });
    try w.interface.flush();
}

test "пустой вход даёт seed" {
    try std.testing.expectEqual(@as(u32, c.CHECKSUM_SEED), checksumOf(""));
}

test "checksum считается по djb2" {
    // 0x1505 * 33 + 'a' = 177670
    try std.testing.expectEqual(@as(u32, 177670), checksumOf("a"));
}
$ zig build && ./zig-out/bin/sum
seed = 0x1505, checksum("zig") = 193513423

$ zig build test --summary all
Build Summary: 4/4 steps succeeded; 2/2 tests passed
test success
+- run test 2 pass (2 total) 558ms MaxRSS:2M
   +- compile test Debug native success 8s MaxRSS:267M
      +- translate-c cached 241ms MaxRSS:34M

$ zig build -Dtarget=x86_64-linux-musl --prefix zig-out-x86
$ file zig-out-x86/bin/sum
zig-out-x86/bin/sum: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, with debug_info, not stripped

Загляни в .zig-cache, там лежит сгенерированный checksum.zig. Две строки из него объясняют, что произошло с заголовком:

pub const CHECKSUM_SEED = @as(c_uint, 0x1505);
pub extern fn checksum(data: [*c]const u8, len: usize) u32;

Макрос стал константой типа c_uint, прототип стал extern fn, а const uint8_t * превратился в [*c]const u8: это C-указатель, особый вид указателя Zig, который может быть null и приводится к [*]const u8 и *const u8 без проверок. checksumOf передаёт text.ptr, и Zig сам приводит [*]const u8 к [*c]const u8. Последняя команда собирает тот же проект, включая C-файл, под Linux x86-64: цель из -Dtarget пробрасывается и в translate-c, и в компиляцию C, и в линковку musl.

Zig из C: export

В обратную сторону мост ещё короче. Слово export перед функцией делает две вещи: даёт ей соглашение о вызовах C и кладёт в таблицу символов под её именем как есть, без искажения. Такую функцию видит любой C-код, а вместе с ним всё, что умеет вызывать C: Python через ctypes, Go через cgo, Node через N-API.

const std = @import("std");

/// export: функция получает C ABI и имя без искажений, её видно из C.
export fn zig_fib(n: u32) u64 {
    var a: u64 = 0;
    var b: u64 = 1;
    var i: u32 = 0;
    while (i < n) : (i += 1) {
        const next = a + b;
        a = b;
        b = next;
    }
    return a;
}

/// Указатель и длина вместо среза: у []const u8 нет C-представления.
export fn zig_count_byte(data: [*]const u8, len: usize, needle: u8) usize {
    return std.mem.count(u8, data[0..len], &.{needle});
}

test "zig_fib" {
    try std.testing.expectEqual(@as(u64, 55), zig_fib(10));
}
#include <stdint.h>
#include <stdio.h>
#include <stddef.h>

/* Прототипы того, что экспортировал Zig. */
uint64_t zig_fib(uint32_t n);
size_t zig_count_byte(const uint8_t *data, size_t len, uint8_t needle);

int main(void) {
    const char *text = "mississippi";
    printf("fib(50) = %llu\n", (unsigned long long)zig_fib(50));
    printf("'s' in %s: %zu\n", text, zig_count_byte((const uint8_t *)text, 11, 's'));
    return 0;
}

Собираем Zig в объектный файл, а линкуем через zig cc, как любой другой .o:

$ zig build-obj ziglib.zig -O ReleaseSafe
$ zig cc main.c ziglib.o -o mixed
$ ./mixed
fib(50) = 12586269025
's' in mississippi: 4

$ nm -g ziglib.o | grep zig_
0000000000000000 T _zig_count_byte
000000000003e300 T _zig_fib

$ zig build-lib ziglib.zig -O ReleaseSafe --name ziglib
$ zig cc main.c libziglib.a -o mixed2

$ zig build-obj ziglib.zig -O ReleaseSafe -target x86_64-linux-musl --name ziglib-linux
$ zig cc -target x86_64-linux-musl main.c ziglib-linux.o -o mixed-linux

Ключ -g оставляет в выводе nm только внешние символы, а буква T означает, что символ определён в секции кода и виден снаружи. Подчёркивание перед именем это соглашение macOS, на Linux символ называется zig_fib, без подчёркивания. Вторая пара команд делает то же через статическую библиотеку, третья собирает смешанную программу под Linux.

Обрати внимание на сигнатуру zig_count_byte: срез []const u8 в C не существует, потому что ABI C знает только указатели и числа, и срез раскладывают на указатель и длину руками. То же правило для структур: наружу можно отдавать только extern struct, чей порядок полей совпадает с C.

Ещё одна деталь, на которую наткнёшься, если попробуешь zig build-exe main.c ziglib.zig -lc. Так собрать не получится: первый файл .zig в команде становится корневым модулем, а корневой модуль исполняемого файла обязан содержать main. Zig не станет искать точку входа в C-файле, если корень его собственный. Поэтому смешанная программа с main на стороне C собирается через объектник или библиотеку, а с main на стороне Zig через build.zig с addCSourceFile, как в предыдущем разделе.

Как устроен раннер курса

Теперь ты знаешь достаточно, чтобы понять, что происходит, когда жмёшь «запустить» в задаче. Задача на языке zig в песочнице курса устроена как папка из двух файлов: src/main.zig, который редактируешь ты, и скрытый tests.zig, который лежит рядом и начинается с const main = @import("src/main.zig");. Скрытый файл ты не видишь, но имена его тестов после прогона показываются в интерфейсе. Задача этого урока проверяется восемью тестами, и тесты в нём импортируют твой стек ровно так, как root.zig импортировал math.zig.

Внутри контейнера выполняется команда из конфигурации раннера:

zig test --test-runner /opt/bs/test_runner.zig tests.zig \
    --cache-dir /work/.zig-cache --global-cache-dir /tmp/zig-global

Флаг --test-runner подменяет стандартный раннер, который печатал 1/5 ... OK, на свой файл test_runner.zig. Семантика та же: тот же список builtin.test_functions, тот же std.testing.allocator с проверкой утечек, а меняется только формат вывода. Вместо строк для человека раннер курса пишет по одной строке JSON на тест:

{"event":"test","name":"push, pop и peek на Stack(u8)","passed":true,"duration_ms":0,"message":null}
{"event":"test","name":"рост при push сверх начальной ёмкости","passed":false,"duration_ms":0,"message":"error.Todo"}

Сервер разбирает эти строки и раскрашивает список тестов. Утечка через std.testing.allocator делает тест проваленным с сообщением memory leak, error.SkipZigTest считается пройденным, а паника в одном тесте роняет процесс, и все тесты после него помечаются как не запущенные. Ошибка компиляции приходит целиком как текст: список тестов пуст, а в панели видно сообщение компилятора, то самое, что печатал @compileError выше.

Флаги кэша нужны из-за устройства контейнера: файловая система только для чтения, а /tmp смонтирован без права запуска, поэтому локальный кэш с тестовым бинарником живёт в /work, а глобальный с объектами стандартной библиотеки в /tmp. Собирается всё под x86_64-linux-musl в режиме Debug, так что проверки переполнения и выхода за границы включены.

Чтобы повторить у себя, хватит двух файлов. Создай папку с той же структурой, положи свой src/main.zig и напиши tests.zig с теми проверками, которые считаешь нужными:

mytask/
├── src/
│   └── main.zig
└── tests.zig
$ cd mytask && zig test tests.zig
1/8 tests.test.push, pop и peek на Stack(u8)...FAIL (Todo)
2/8 tests.test.рост при push сверх начальной ёмкости...FAIL (Todo)
...
8/8 tests.test.типы мемоизируются...OK
1 passed; 0 skipped; 7 failed.

Так выглядит заготовка задачи до того, как ты её решил: стандартный раннер вместо JSON, но те же тесты и тот же результат. Одна ловушка напоследок. Если скопировать папку задачи целиком в соседнюю и запустить zig test tests.zig во второй копии, можно получить бинарник из первой: глобальный кэш Zig нашёл сборку с тем же корневым путём tests.zig, проверил файлы, записанные в её манифесте, и решил, что ничего не менялось. Лечится флагом --global-cache-dir с отдельной папкой, а лучше не держать две копии одной задачи рядом.

Практика

Задача собирает воедино первую половину урока: дженерик как функция над типами, unmanaged-хранилище с аллокатором в каждом вызове, рост через realloc и @compileError как проверка контракта. Тесты гоняют Stack(u8), Stack([]const u8) и стек структур, проверяют рост при push сверх начальной ёмкости и утечки через std.testing.allocator. Пиши прямо здесь, а если хочешь локально, собери папку по схеме из предыдущего раздела.

Упражнения

Итоги

  • comptime это обычный Zig, исполненный внутри компилятора: параметры, блоки и переменные с этим словом живут до кодогенерации, а результат может быть числом, строкой в rodata или типом.
  • Дженерик в Zig это fn(comptime T: type) type. Вызовы с одинаковыми аргументами мемоизируются и возвращают один и тот же тип, поэтому Stack(u8) == Stack(u8).
  • @typeInfo даёт описание типа как значение, @hasField и @hasDecl отвечают на вопросы о нём, @Int и @Enum строят типы из описаний. Старого @Type в 0.16 нет.
  • inline for и inline while разворачиваются до кодогенерации и нужны там, где тело зависит от типа элемента. Скорость они не обещают, а копий кода делают столько, сколько итераций.
  • @compileError внутри switch по @typeInfo превращает утиную типизацию в проверку контракта с человеческим сообщением.
  • zig test собирает только то, до чего дотянулся анализ: тесты вложенных файлов подключай через test { _ = module; }. --test-filter оставляет тесты по подстроке, std.testing.allocator проваливает тест с утечкой.
  • build.zig описывает граф шагов на самом Zig: модули через addModule и createModule, артефакты через addExecutable и addTest с одним root_module, шаги через b.step.
  • zig cc -target arch-os-abi собирает C под любую платформу без внешнего тулчейна; архитектура ELF читается по байтам 18 и 19 заголовка.
  • C в Zig приходит через addTranslateC и модуль в imports, @cImport остался как наследие. Zig в C уходит через export fn с C-совместимыми типами и линкуется как объектник или статическая библиотека.
  • Раннер курса запускает zig test --test-runner /opt/bs/test_runner.zig tests.zig в папке с твоим src/main.zig; локально та же папка и zig test tests.zig дают тот же результат.

Дальше

Zig-минимум закрыт: у тебя есть язык, тесты, сборка и мост в C. Дальше блок про биты и числа: как целые и числа с плавающей точкой лежат в памяти, что происходит при переполнении и почему 0.1 + 0.2 не равно 0.3. Там будут головоломки в стиле «сделай через три операции», и comptime пригодится, чтобы проверять ответы на этапе компиляции. За ним начинается машинный уровень x86-64, и виджет из этого урока станет обычным рабочим инструментом: читать ассемблер своего кода ты будешь каждый урок.

домашка

Домашка