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 в наборе ошибок. Три стратегии работы с отменой:
- Пробросить дальше — просто позволить ошибке всплыть по стеку.
- Повторно установить отмену — вызвать
io.recancel()после локальной обработки. - Защитить участок — временно отключить отмену через
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.ThreadSafeAllocator—ArenaAllocatorтеперь потокобезопасен сам по себе;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.