Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
comptime, сборка, тесты и C
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
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.
Разбери четыре места.
- Параметр
comptime n: usizeвrepeatнужен потому, чтоnпопадает в тип возвращаемого значения[n]u8, а тип обязан быть известен до кодогенерации. - Функция
fibничем не помечена, и тем не менееfib_tableзаполняется на этапе компиляции: инициализатор глобальной константы это comptime-контекст, и любая функция, которую там вызвали, исполняется интерпретатором. - Внутри
fib_tableживёт обычнаяvar table, это и естьcomptime var: переменная, которая существует только во время компиляции. 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 по срезу: там тот же оптимизатор, но цикл остался циклом, потому что длина известна только в рантайме.
Исходник
кликни по полю структуры
const Vec3 = struct {};
const Pixel = struct {};
const Header = 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;
}
sum_vec3:
movsxd rax, dword ptr [rdi]
movsxd rcx, dword ptr [rdi + 4]
add rcx, rax
movsxd rax, dword ptr [rdi + 8]
add rax, rcx
ret
fn sumFields(value: Pixel) i64 {
var total: i64 = 0;
total += value.r;
total += value.g;
total += value.b;
total += value.a;
return total;
}
sum_pixel:
mov ecx, dword ptr [rdi]
mov edx, ecx
movzx esi, cl
movzx eax, ch
shr ecx, 16
shr edx, 24
add esi, edx
movzx ecx, cl
add eax, esi
add eax, ecx
ret
fn sumFields(value: Header) i64 {
var total: i64 = 0;
total += value.magic;
total += value.version;
total += value.flags;
return total;
}
sum_header:
mov rax, qword ptr [rdi]
mov rcx, rax
mov edx, eax
shr rax, 32
shr rcx, 48
movzx esi, ax
add rsi, rdx
movzx eax, cl
add rax, rsi
ret
Три поля по четыре байта: три загрузки со знаковым расширением до 64 бит и два сложения. Ни счётчика, ни перехода.
Четыре байта подряд: LLVM загрузил их одним 32-битным mov и растащил по регистрам сдвигами. Порядок сложения переставлен, сумма четырёх u8 помещается в 32 бита, поэтому весь счёт идёт в eax.
Семь байт полей плюс байт выравнивания дают восемь: компилятор прочитал структуру одним qword и вырезал поля сдвигами. Читать байт padding в ReleaseFast можно, его значение никуда не попадает.
Для сравнения: обычный 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, и виджет из этого урока станет обычным рабочим инструментом: читать ассемблер своего кода ты будешь каждый урок.
домашка