Zig 0.17.0: инкрементальная сборка, новый линкер и билд-система, которая стала сервером

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 бэкенд.

Предыдущий
Наверх