Yesterday

Zig 0.16.0: Io как интерфейс, «сочный main» и новые билтины

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

Язык всё ещё не достиг версии 1.0, поэтому каждый релиз может переворачивать привычные вещи с ног на голову. Версия 0.16.0 — как раз такой случай. Это не косметическое обновление и не набор багфиксов. Здесь перестроено ядро стандартной библиотеки: весь ввод-вывод теперь проходит через единый интерфейс Io, который можно подменять, тестировать и отменять. Появился новый способ инициализации программы — «сочный main». @Type разобран на восемь специализированных билтинов (builtins — встроенные функции компилятора, которые начинаются с @). Компилятор стал строже к unsafe-паттернам, а в тулчейне обновился LLVM и появились новые платформы.

Изменений много, и перечислять каждое — верный способ усыпить читателя. Поэтому ниже — только то, что действительно влияет на повседневную работу с языком: с примерами кода, сравнениями «было — стало» и объяснением, зачем всё это нужно.

«Сочный main»

Раньше типичный main в Zig начинался с ритуальной инициализации: достать аллокатор, разобрать аргументы, подготовить I/O. Теперь всё это приходит одним параметром:

const std = @import("std");

pub fn main(init: std.process.Init) !void {
    const gpa = init.gpa;             // аллокатор общего назначения
    const io = init.io;               // экземпляр Io для всех операций ввода-вывода
    const arena = init.arena.allocator(); // арена для временных данных

    // аргументы командной строки — тоже через init
    const args = try init.minimal.args.toSlice(arena);

    std.log.info("первый аргумент: {s}", .{args[0]});
}

Никакого шаблонного кода для запуска. Аллокатор, I/O и арена готовы к использованию — остаётся писать логику.

Io как интерфейс: главное архитектурное изменение

Это центральная идея релиза: все операции ввода-вывода теперь принимают параметр io. Файлы, сеть, процессы, генерация случайных чисел, примитивы синхронизации — всё проходит через единый интерфейс std.Io.

Зачем это нужно:

  • Тестируемость. В тестах используется std.testing.io — не нужно подменять реальный I/O заглушками вручную.
  • Сменяемость бэкенда. Один и тот же код работает поверх потоков, io_uring, kqueue или Grand Central Dispatch.
  • Отмена операций. Любую I/O-операцию можно отменить через единый механизм.

Бэкенды Io

Для миграции с 0.15.x подходит Io.Threaded — он полнофункционален и хорошо протестирован.

Простой пример: HTTP-запрос

Вот как выглядит HTTP HEAD-запрос с новым интерфейсом. DNS-резолвинг, TCP-соединение и отмена — всё работает через io:

const std = @import("std");
const Io = std.Io;

pub fn main(init: std.process.Init) !void {
    const gpa = init.gpa;
    const io = init.io;
    const args = try init.minimal.args.toSlice(init.arena.allocator());

    // DNS-имя парсится из аргумента командной строки
    const host_name: Io.net.HostName = try .init(args[1]);

    var http_client: std.http.Client = .{ .allocator = gpa, .io = io };
    defer http_client.deinit();

    var request = try http_client.request(.HEAD, .{
        .scheme = "http",
        .host = .{ .percent_encoded = host_name.bytes },
        .port = 80,
        .path = .{ .percent_encoded = "/" },
    }, .{});
    defer request.deinit();

    try request.sendBodiless();

    var redirect_buffer: [1024]u8 = undefined;
    const response = try request.receiveHead(&redirect_buffer);
    std.log.info("статус: {d} {s}", .{ response.head.status, response.head.reason });
}

Future и Group: конкурентность без колбэков

Два ключевых инструмента для параллельного выполнения — Future и Group.

Future — это отложенный результат одной операции. Запускаем функцию асинхронно, а результат получаем позже:

// запускаем открытие файла асинхронно
var task = io.async(Io.Dir.openFile, .{ .cwd(), io, "data.txt", .{} });

// при отмене или ошибке — корректно освобождаем ресурс
defer if (task.cancel(io)) |file| file.close(io) else |_| {};

Group управляет множеством параллельных задач. Вот «сортировка сном» — каждый элемент засыпает на время, пропорциональное своему значению:

const std = @import("std");
const Io = std.Io;

test "sleep sort" {
    const io = std.testing.io; // тестовый Io — без настоящих системных вызовов

    const rng_impl: std.Random.IoSource = .{ .io = io };
    const rng = rng_impl.interface();

    var array: [10]i32 = undefined;
    for (&array) |*elem| elem.* = rng.uintLessThan(u16, 1000);

    var sorted: [10]i32 = undefined;
    var index: std.atomic.Value(usize) = .init(0);

    var group: Io.Group = .init;
    defer group.cancel(io); // отмена всех незавершённых задач при выходе

    // запускаем все задачи параллельно
    for (&array) |elem| group.async(io, sleepAppend, .{ io, &sorted, &index, elem });

    try group.await(io); // дожидаемся завершения всех

    // проверяем, что массив отсортирован
    for (sorted[0 .. sorted.len - 1], sorted[1..]) |a, b| {
        try std.testing.expect(a <= b);
    }
}

fn sleepAppend(io: Io, result: []i32, i_ptr: *std.atomic.Value(usize), elem: i32) !void {
    // спим пропорционально значению элемента
    try io.sleep(.fromMilliseconds(elem), .awake);
    result[i_ptr.fetchAdd(1, .monotonic)] = elem;
}

Отмена операций

Любая I/O-операция может быть отменена — через error.Canceled в наборе ошибок. Три стратегии работы с отменой:

  1. Пробросить дальше — просто позволить ошибке всплыть по стеку.
  2. Повторно установить отмену — вызвать io.recancel() после локальной обработки.
  3. Защитить участок — временно отключить отмену через io.swapCancelProtection().

Новые билтины вместо @Type

@Type был универсальным, но громоздким. Теперь вместо одного обобщённого билтина — восемь специализированных: @Int, @Tuple, @Pointer, @Fn, @Struct, @Union, @Enum и @EnumLiteral.

@Int и @Tuple — самый частый случай

// Было: 0.15.x
const U10 = @Type(.{ .int = .{ .signedness = .unsigned, .bits = 10 } });

// Стало: 0.16.0
const U10 = @Int(.unsigned, 10);
// Было: 0.15.x — 12 строк на объявление кортежа
const MyTuple = @Type(.{ .@"struct" = .{
    .layout = .auto,
    .fields = &.{.{
        .name = "0",
        .type = u32,
        .default_value_ptr = null,
        .is_comptime = false,
        .alignment = @alignOf(u32),
    }, .{
        .name = "1",
        .type = [2]f64,
        .default_value_ptr = null,
        .is_comptime = false,
        .alignment = @alignOf([2]f64),
    }},
    .decls = &.{},
    .is_tuple = true,
} });

// Стало: 0.16.0 — одна строка
const MyTuple = @Tuple(&.{ u32, [2]f64 });

@Struct — генерация структур в comptime

@Struct позволяет создавать структуры с произвольными полями во время компиляции. Это полезно для метапрограммирования — например, генерации обёрток над перечислениями:

const std = @import("std");

// создать структуру, где каждому варианту перечисления соответствует поле
fn EnumFieldMap(comptime E: type, comptime FieldType: type) type {
    return @Struct(
        .auto,                          // layout
        null,                           // backing integer (нет)
        std.meta.fieldNames(E),         // имена полей — из перечисления
        &@splat(FieldType),             // все поля одного типа
        &@splat(.{}),                   // без дополнительных атрибутов
    );
}

@Enum — динамические перечисления

// создаём enum с явным backing-типом и значениями
const Color = @Enum(
    u8,            // backing integer
    .exhaustive,   // исчерпывающий (не допускает неизвестных значений)
    &.{ "red", "green", "blue" },
    &.{ 0, 1, 2 },
);

Что не стало билтином

Для float, array, opaque, optional и error_union отдельные билтины не нужны — используется обычный синтаксис языка. Наборы ошибок (error sets) тоже больше нельзя создавать через рефлексию: объявляйте их явно через error { ... }.

Безопасность на уровне языка

Запрет на возврат адреса локальной переменной

Классическая ошибка, которая в C приводит к use-after-free, теперь ловится на этапе компиляции:

fn foo() *i32 {
    var x: i32 = 1234;
    return &x;
    // ошибка компиляции: returning address of expired local variable 'x'
}

Указатели в packed-структурах запрещены

Указатели внутри packed struct и packed union больше не допускаются — вместо них нужно использовать usize с явным преобразованием через @ptrFromInt/@intFromPtr. Это исключает неопределённое поведение при работе с битовыми представлениями.

packed union: все поля одного размера

Раньше можно было объявить packed union с полями разного размера — неиспользуемые биты просто игнорировались. Теперь это ошибка:

// Ошибка компиляции в 0.16.0: поля разного размера
const U = packed union {
    x: u8,
    y: u16,
};

// Правильно: явный backing-тип и выравнивание
const U = packed union(u16) {
    x: packed struct(u16) {
        data: u8,
        padding: u8 = 0,
    },
    y: u16,
};

Явные backing-типы в extern-контексте

Перечисления и packed-типы с неявным backing-типом больше нельзя использовать в extern-контексте. Компилятор требует указать тип явно:

// Ошибка в 0.16.0
const Status = enum { ok, err };
export var s: Status = .ok;

// Правильно
const Status = enum(u8) { ok, err };
export var s: Status = .ok;

Запрет на runtime-индексацию векторов

SIMD-векторы больше нельзя индексировать переменной времени выполнения. Если нужен поэлементный доступ, приведите вектор к массиву:

// Ошибка в 0.16.0
for (0..vector_len) |i| {
    _ = vector[i]; // runtime index запрещён
}

// Правильно: приводим вектор к массиву
const info = @typeInfo(@TypeOf(vector)).vector;
const array: [info.len]info.child = vector;
for (&array) |elem| {
    _ = elem;
}

Конвертация чисел: меньше рутинного кода

Маленькие целые автоматически приводятся к float

Целочисленные типы до 24 бит включительно теперь неявно приводятся к f32 без потери точности, потому что мантисса f32 вмещает все их значения:

var foo_int: u24 = 123;
var foo_float: f32 = foo_int; // работает без @floatFromInt

var bar_int: u25 = 123;
var bar_float: f32 = @floatFromInt(bar_int); // u25 не помещается — нужна явная конвертация

@floor, @ceil, @round, @trunc конвертируют в целые

Раньше для округления f32 до u8 требовалась цепочка @intFromFloat(@round(value)). Теперь @round (и другие) умеют конвертировать напрямую:

const std = @import("std");

test "round to int" {
    try example(12, 12.34);
    try example(13, 12.50);
}

fn example(expected: u8, value: f32) !void {
    const actual: u8 = @round(value); // float -> int за один шаг
    try std.testing.expectEqual(expected, actual);
}

@intFromFloat для этих случаев помечен как deprecated.

Стандартная библиотека: что ещё нового

Deflate-сжатие на чистом Zig

В стандартной библиотеке появилась реализация deflate-компрессора. Раньше была только декомпрессия. Три режима работы:

  • Standard — полноценное сжатие (по умолчанию);
  • Raw — хранение без сжатия;
  • Huffman — сжатие кодами Хаффмана без поиска совпадений.

Производительность сопоставима с zlib:

Для большинства задач разница в степени сжатия незаметна, а выигрыш в скорости ощутим. И никакой зависимости от C-библиотеки.

ArenaAllocator стал потокобезопасным

std.heap.ArenaAllocator теперь использует атомарные операции вместо мьютекса. В однопоточном режиме скорость не изменилась, а при использовании из нескольких потоков (до ~7) — заметно быстрее, чем старый вариант с ThreadSafeAllocator. Сам ThreadSafeAllocator удалён за ненадобностью.

Новые криптоалгоритмы

Добавлены реализации AES-SIV, AES-GCM-SIV, а также семейство Ascon (Ascon-AEAD, Ascon-Hash, Ascon-CHash). Всё на чистом Zig, без внешних зависимостей.

@cImport уходит в build-систему

@cImport помечен как deprecated. Трансляция C-заголовков теперь выполняется через addTranslateC в build.zig:

// build.zig
const translate_c = b.addTranslateC(.{
    .root_source_file = b.path("src/c.h"),
    .target = target,
    .optimize = optimize,
});
translate_c.linkSystemLibrary("glfw", .{});
translate_c.linkSystemLibrary("epoxy", .{});

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

А в коде вместо @cImport — обычный импорт:

const c = @import("c");

Сами заголовки живут в обычном .h-файле:

// src/c.h
#include <stdio.h>
#include <GLFW/glfw3.h>
#include <epoxy/gl.h>

Преимущества: C-трансляция встроена в граф зависимостей сборки, работает кэширование, а build.zig полностью описывает все внешние зависимости.

Компилятор и тулчейн

LLVM 21

Компилятор обновлён до LLVM 21. Из-за регрессии в LLVM временно отключена векторизация циклов. Её вернут, когда апстрим исправит баг.

Новые платформы второго уровня (тестируются в CI)

К уже поддерживаемым платформам добавлены:

  • aarch64-freebsd, aarch64-netbsd;
  • x86_64-freebsd, x86_64-netbsd, x86_64-openbsd;
  • loongarch64-linux, powerpc64le-linux, s390x-linux.

Появилась базовая поддержка экзотических архитектур: Alpha, KVX, MicroBlaze, OpenRISC, PA-RISC, SuperH. Убрана поддержка Oracle Solaris, IBM AIX и IBM z/OS (illumos остаётся).

Фаззер Smith

Новый AST-фаззер Smith генерирует синтаксически корректные программы на Zig и проверяет, что компилятор не падает. Поддерживается мультипроцессный фаззинг, бесконечный режим и автоматическое сохранение краш-дампов. С помощью Smith уже найдены и исправлены десятки багов.

Пакетный менеджер

Два полезных улучшения:

  • Локальные переопределения пакетов — можно указать локальный путь вместо удалённой зависимости, удобно для разработки форков.
  • Загрузка в каталог проекта — пакеты можно скачивать не в глобальный кэш, а в директорию проекта.

Миграция с 0.15.x: всё в одном месте

Основной паттерн миграции: добавить параметр io к вызовам I/O-функций. Получить io проще всего через «сочный main» (std.process.Init), а в тестах — через std.testing.io.

Файловая система

Примитивы синхронизации

Процессы

Случайные числа

Время

Коды ошибок

Удалено без замены

  • std.heap.ThreadSafeAllocatorArenaAllocator теперь потокобезопасен сам по себе;
  • std.Thread.Mutex.Recursive — перепроектируйте код без рекурсивных блокировок;
  • std.once — избегайте ленивой инициализации глобального состояния;
  • std.SegmentedList — используйте ArrayList или другие контейнеры;
  • std.Io.GenericReader, std.Io.AnyReader, std.Io.FixedBufferStream — заменены новым I/O-интерфейсом;
  • std.fs.getAppDataDir — без замены;
  • Z/W-суффиксные варианты функций файловой системы (realpathZ, openDirAbsoluteZ, deleteDirZ и т. д.) — используйте основные функции из std.Io.Dir.

Итого

Zig 0.16.0 — это прежде всего архитектурный сдвиг. Io-интерфейс пронизывает всю стандартную библиотеку, делая код тестируемым и переносимым между бэкендами. Новые билтины упрощают метапрограммирование, а усиленные проверки компилятора ловят ошибки, которые раньше приводили к UB в рантайме.

Миграция с 0.15.x потребует усилий — API файловой системы, потоков и процессов переехали почти целиком. Но паттерн изменений единообразный: добавить io в параметры и обновить пути импортов. Полный список изменений — в официальных release notes.