
Zig 0.17.0: инкрементальная сборка, новый линкер и билд-система, которая стала сервером
В апреле я разбирал Zig 0.16.0 — версию, где всю стандартную библиотеку пронизал интерфейс Io, а main получил один параметр вместо ритуальной инициализации. Пять месяцев спустя — 0.17.0. Io на месте, «сочный main» на месте, зато перестроено то, что вы замечаете каждый рабочий день: сборка.
206 авторов, 925 коммитов, и центр релиза — не синтаксис, а билд-система: её разделили на два процесса, ELF-линкер довели до состояния, когда, по ожиданиям разработчиков, инкрементальная компиляция работает «для всех на x86_64-linux», а для IDE появился отдельный протокол. Ниже — то, что реально меняет повседневную работу, с примерами, которые я собрал и проверил на установленном 0.17.0.
Главное: zig build стал быстрее и разговорчивее
В 0.16 сборка выполняла ваш build.zig и тут же шла по графу задач в одном процессе. В 0.17 процессов два: configurer выполняет build.zig, maker управляет пакетами и исполняет граф. Maker собирается один раз при установке Zig и при правках build.zig больше не пересобирается — перезапускается только конфигурация.
Побочный эффект: конфигурацию можно сериализовать и отдать наружу. Отсюда два новых флага:
$ zig build --print-configuration
.{
.search_prefixes = .{},
.default_step = 0,
.top_level_steps = .{
.install = 0,
.uninstall = 1,
.run = 2,
.@"test" = 3,
},
...
Это .zon-представление графа сборки. А с флагом --listen=- билд-система начинает говорить по протоколу — Build Server Protocol. IDE подключается и видит граф в реальном времени: какие шаги есть, что уже собралось, какие файлы появились. Раньше для этого приходилось форкать build runner, теперь это часть тулчейна.
Есть и цена: разделение процессов сломало ZLS — с 0.17.0 языковой сервер не работает, и команды Zig и ZLS пока договариваются, как его чинить.
Из практичного для своих проектов: у Run-шага в build.zig больше нет b.args — вместо него addPassthruArgs(). Свежий zig init уже генерирует код с ним:
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
run_cmd.addPassthruArgs();
$ zig build run -- hello world
All your codebase are belong to us.
info: arg: ./zig-out/bin/bs
info: arg: hello
info: arg: world
Раньше build.zig мог читать аргументы после -- и менять поведение сборки. Теперь он их не видит — зато при смене аргументов скрипт не пересобирается.
Инкрементальная компиляция: наконец работает
Самое ожидаемое. Раньше при правке одной строки компилятор пересобирал всё целиком. Теперь для проектов под x86_64-linux можно собрать только изменившееся:
$ zig build -fincremental --watch
--watch следит за файлами и пересобирает при изменениях. Работает это благодаря новому ELF-линкеру из 0.16: за этот цикл он получил полную поддержку x86_64 и SPARC64, генерацию статических и разделяемых библиотек, GOT, DWARF и версионирование символов GNU. По умолчанию линкер всё ещё выключен, но в паре с -fincremental включается сам.
А вот на macOS инкрементальная сборка пока падает. Я попробовал на своей машине:
error: thread ... panic: TODO(MachO2): load archive
Причина в Mach-O линкере: он до инкрементальной сборки просто не дорос, и ваша конфигурация тут ни при чём. В release notes формулировка аккуратная: «it is now possible for most projects targeting x86_64-linux». Так что на Linux — пользуйтесь, на Mac — ждите следующих релизов: новый Mach-O линкер с поддержкой инкрементальной компиляции там обещают.
Билд-система как пакетный менеджер
Управление пакетами полностью переехало из компилятора в билд-систему: zig fetch, zig init, zig libc и zig cache-cat больше не живут в компиляторе — это функции сборки.
Заодно переработали кэш: он перешёл на бинарный формат, файлы стали примерно на четверть меньше, а попадания в кэш, по замерам разработчиков, ускорились на 5–10%. Покопаться в содержимом zig-cache можно как раз через zig cache-cat.
Два улучшения для разработки форков:
--pkg-pathи переменнаяZIG_LOCAL_PKG_DIRподменяют удалённую зависимость локальным путём — работает и дляzig fetch, и дляzig build;zig fetch --saveскачивает пакет не только в глобальный кэш, но и в каталог проекта.
Компилятор: SPIR-V, loongarch и почти готовый wasm
Отдельные бэкенды:
- SPIR-V: бэкенд стал многопоточным, как остальные,
OpCapabilityиOpExtensionпереехали из inline-ассемблера в-mcpu, за цикл закрыто 22 бага; - loongarch64: появился начальный self-hosted бэкенд, пока экспериментальный и непригодный для использования;
- WebAssembly: self-hosted бэкенд проходит 2060/2054 (100%) тестов поведения против LLVM, но по умолчанию не включается в дебаг-режиме — нет поддержки debug info;
- aarch64: прогресс упирается в линкер, над которым как раз работали в этом цикле.
Язык: чистка
0.17 продолжает линию 0.16 на ужесточение. Пара изменений ломает код молча — с них и начну.
@bitCast теперь endian-agnostic — и может тихо сломать код
@bitCast переопределён: он переинтерпретирует логическое битовое представление значения, а не байты в памяти. Для целых чисел, float и packed-типов поведение прежнее, а вот с extern и массивами — два сюрприза.
Первый: касты с участием extern struct и extern union запрещены. Раньше такой код работал:
const TwoBytes = extern struct { b0: u8, b1: u8 };
const bytes: TwoBytes = .{ .b0 = 0x12, .b1 = 0xAB };
const int: u16 = @bitCast(bytes);
Теперь это ошибка компиляции:
error: cannot @bitCast from 'TwoBytes'
Для чтения тех же байтов как значения другого типа (type punning) используйте @ptrCast:
const int_ptr: *align(1) const u16 = @ptrCast(&bytes);
Второй сюрприз опаснее: поменялась семантика @bitCast для массивов и векторов, и код может сломаться без ошибки компиляции. Раньше результат зависел от порядка байтов в памяти. Теперь логические биты элементов склеиваются по порядку, начиная с первого, и первый элемент всегда становится младшим байтом — на любой платформе:
test "bitcast array to integer" {
const bytes: [2]u8 = .{ 0x12, 0xAB };
const int: u16 = @bitCast(bytes);
// в 0.17 результат одинаковый и на little-, и на big-endian
try std.testing.expectEqual(0xAB_12, int);
}
На little-endian новое поведение почти совпадает со старым, поэтому баг всплывёт не сразу: сломается код, который рассчитывал на старый результат на big-endian (там раньше получалось 0x12_AB) или опирался на порядок байтов в памяти. После обновления пересмотрите все @bitCast с массивами и векторами — это прямая рекомендация release notes. Заодно @bitCast с enum’ом в результате научился проверять тег на валидность.
@hasDecl теперь только про pub
Раньше @hasDecl возвращал true и для приватных объявлений в том же файле. Теперь — только для публичных, и поведение больше не зависит от того, в каком файле вызов:
const Foo = struct {
bar: i32,
const baz = 1;
pub var quux = "xxx";
};
test "@hasDecl example" {
try std.testing.expect(!@hasDecl(Foo, "bar"));
try std.testing.expect(!@hasDecl(Foo, "baz")); // было true
try std.testing.expect(@hasDecl(Foo, "quux"));
}
errdefer потерял захват ошибки
Классический паттерн errdefer |err| ... больше не компилируется:
error: expected block or expression, found '|'
Миграция — вынести тело в отдельную функцию и ловить ошибку снаружи:
fn processOneTarget() void {
processOneTargetInner() catch |err|
std.debug.panic("panic: {s}", .{@errorName(err)});
}
fn processOneTargetInner() !void {
return error.SomeError;
}
Мелочи, которые ломают код
void{}удалён, пишите просто{};i0удалён за бессмысленностью, заменяйте наu0;- умножение массива
[1]u8{0} ** 4→@splat(0); internalиlink_onceвGlobalLinkageудалены: вместоlink_once—weak, вместоinternal— просто не экспортировать символ;@cImportудалён окончательно, аaddTranslateCиз 0.16 объявлен deprecated — трансляция C переехала в отдельный пакет translate-c.
Новые билтины
@backingInt и @fromBackingInt
Заменяют @intFromEnum/@enumFromInt, которые объявлены deprecated. @backingInt отдаёт backing-целое любого enum’а, битпака с явным backing-типом или активного тега tagged union; @fromBackingInt собирает значение обратно — для enum’ов и битпаков:
const Color = enum(u8) { red, green, blue };
const c: Color = .green;
const n: u8 = @backingInt(c);
const back: Color = @fromBackingInt(n);
green -> 1 -> green
Старые вызовы zig fmt переписывает автоматически.
@divCeil
Целочисленное деление с округлением вверх — теперь встроенной функцией, без std.math.divCeil(a, b) catch unreachable:
@divCeil(5, 3) // == 2
@divCeil(-5, 3) // == -1
@SpirvType
Объявляет SPIR-V-типы (изображения, сэмплеры), у которых нет аналога в системе типов Zig. Раньше такое делалось только через inline-ассемблер. Пример для фрагментного шейдера:
const Image = @SpirvType(.{ .image = .{
.usage = .{ .sampled = f32 },
.format = .unknown,
.dim = .@"2d",
.depth = .not_depth,
.arrayed = false,
.multisampled = false,
.access = .unknown,
} });
const SampledImage = @SpirvType(.{ .sampled_image = Image });
Компилируется в spirv.spv командой zig build-obj spirv.zig -target spirv32-vulkan.
Стандартная библиотека
SafeAllocator вместо DebugAllocator
std.heap.DebugAllocator и std.heap.Check объявлены deprecated, на смену пришёл потокобезопасный SafeAllocator. Гарантии жёсткие: deinit сообщает обо всех утечках, двойное освобождение и гонки — паника или segfault, память почти не переиспользуется, поэтому запись после освобождения чаще всего тоже падает.
var gpa: std.heap.SafeAllocator = .init(std.heap.page_allocator, .{});
defer _ = gpa.deinit();
const a = gpa.allocator();
arena.print вместо std.fmt.allocPrint
Печать переехала в аллокатор:
// было
try std.fmt.allocPrint(arena, "{s}={d}", .{ x, y });
// стало
try arena.print("{s}={d}", .{ x, y });
ArrayList тоже подчистили: getLastOrNull переименован в last(), а вместо getLast — last().?.
Рефлексия в стиле «struct of arrays»
@typeInfo для структур и union’ов теперь отдаёт имена и типы полей двумя параллельными массивами:
const info = @typeInfo(Foo).@"struct";
inline for (info.field_names, info.field_types) |name, T| {
std.debug.print("{s}: {s}\n", .{ name, @typeName(T) });
}
a: u32
b: f64
c: bool
Мелочи
std.bit_set.IntegerBitSet→std.bit_set.Integer,ArrayBitSet→Array(а.initEmpty→.empty);std.gpuпереименован вstd.spirv;mem.eqlиmem.findDiffтеперь корректно работают сNaN: короткое замыкание по одинаковым срезам для float отключили;Uriиnet.HostNameрасцеплены:Uri.getHostпереехал вHostName.fromUri;@import("builtin").os/cpu/abi/object_formatустарели и будут удалены в 0.18 — переходите на.target.osи т. д.
Тулчейн
LLVM обновлён до 22.1.8, glibc — до 2.44, musl — до 1.2.5, заголовки — Linux 7.2 и macOS 27.0. Векторизация циклов всё ещё отключена из-за того же бага LLVM, что и в 0.16: исправление уже в мастере LLVM, но дойдёт только с LLVM 23 — то есть с Zig 0.18.
Появился zig objdump — он понадобился для snapshot-тестов линкера и разработки COFF. А у zig fmt — флаг --complexity, который считает токены и узлы AST. Полезно, когда правка не меняет число строк, но меняет сложность:
info: src/link/Elf/ZigObject.zig: tokens=20783 nodes=10274
info: total: tokens=97031 nodes=47621
Итого
Zig 0.17.0 меняет прежде всего инфраструктуру: билд-система разделилась и стала быстрее, линкер подтянул инкрементальную компиляцию на x86_64-linux, а язык продолжил избавляться от неоднозначностей — иногда ценой тихой поломки кода вроде @bitCast с массивами.
Если обновляетесь с 0.16, порядок такой: сначала zig fmt — он сам перепишет @intFromEnum/@enumFromInt, — потом руками: аудит @bitCast с массивами и векторами, @hasDecl по приватным декларациям, errdefer |err|, b.args в Run-шагах и addTranslateC. В 0.16 главным паттерном миграции было «добавь io»; теперь главное — пережить разделение билд-системы и тихие изменения семантики. Полный список — в официальных release notes.
Известные регрессии релиза: compiler-rt для soft float на x86, std.debug.simple_panic, слабые символы zig libc, response-файлы в Run-шаге и SPIR-V бэкенд.