В Zig 0.16 нет какой‑то специальной «самовложенности» или рекурсивности как фичи языка — то, что выглядит как вложенность или рекурсия в библиотечных функциях, обычно объясняется тремя вещами:
- Явной передачей зависимостей (DI).
- Композицией интерфейсов (особенно I/O и аллокаторов).
- Природой системных абстракций (например, обёртки над ОС, которые сами используют другие абстракции).
Ключевые изменения в 0.16, которые сильнее всего меняют стиль стандартной библиотеки:
- Точка входа стала DI‑контейнером:
mainтеперь принимаетstd.process.Init, который несёт аллокаторы, I/O‑интерфейс, окружение, preopens и т. д. - I/O сделан интерфейсом: вместо жёстко заданных функций ввода‑вывода теперь используется
std.Ioс разными реализациями (Threaded,Evented,Uringи т. п.). - Убран глобальный доступ к окружению и другим ресурсам: всё, что раньше можно было достать «из ниоткуда», теперь передаётся явно.
Из‑за этого многие функции теперь принимают дополнительные параметры (аллокатор, I/O, конфиг), а внутри вызывают другие функции, которым тоже нужны эти параметры. Отсюда и возникает ощущение «вложенности».
Типичный пример из Zig 0.16:
pub fn main(init: std.process.Init) !void {
const gpa = init.gpa;
const io = init.io;
const stdout = try std.io.getStdOut();
try stdout.writer().writeAll("Hello\n");
}В более «ручном» стиле (ближе к тому, как это выглядит внутри std):
try stdout.writer().writeStreamingAll(io, "Hello\n");Здесь io — это I/O‑интерфейс, который может быть, например, асинхронным. Функция writeStreamingAll сама делегирует работу нижележащей реализации, которая может дальше вызывать ещё более низкоуровневые функции. Это не рекурсия ради рекурсии, а композиция слоёв абстракции:
- верхний уровень: удобный API для пользователя;
- средний уровень: работа с I/O‑интерфейсом;
- нижний уровень: конкретная реализация (потоки, event loop, io_uring и т. п.).
Это фундаментальный принцип Zig: никаких скрытых выделений памяти и скрытых потоков управления.
- Если функция может выделить память, она обязана принимать
Allocator. - Если она делает I/O, она должна принимать I/O‑интерфейс (в 0.16 это особенно важно для async без «раскраски» функций).
Поэтому типичная сигнатура выглядит так:
fn doSomething(allocator: std.mem.Allocator, io: anytype, args: anytype) !voidА внутри она вызывает другие функции, передавая им те же allocator и io. Отсюда и «лестница» вызовов, которая воспринимается как вложенность.
Иногда в std встречаются:
- Взаимные зависимости между модулями (не рекурсивные вызовы, а циклические импорты на уровне модулей, которые Zig разрешает через
comptimeи отложенное разрешение). - Рекурсивные структуры данных (списки, деревья, графы) — но это не особенность Zig, а естественное представление таких структур.
- Асинхронные «петли»:
Group,Future, ожидание набора задач. Это не рекурсивный вызов функций, а паттерн параллелизма.
Если вы видите в коде что‑то вроде:
const r = try innerFunction(allocator, io, config);
return try outerFunction(allocator, io, r);это просто последовательная композиция, а не рекурсия.
Такая «вложенность» даёт:
- Контроль над аллокациями: вы всегда видите, где и какой аллокатор используется.
- Тестируемость: можно подменить I/O на
Io.Failingили аллокатор на аллокатор с проверкой утечек. - Гибкость реализации: один и тот же API работает поверх потоков, evented‑модели, io_uring и т. д. без изменения сигнатур функций.
- Отсутствие «async‑заражения»: синхронные и асинхронные функции имеют одинаковую сигнатуру — разница только в переданной реализации I/O.
- Посмотрите модуль
std.ioи его реализации:Threaded,Eventedи т. п. - Изучите
std.process.Initи примерыmainв новой парадигме. - Просмотрите тесты в репозитории Zig: там много примеров, где явно передаются аллокаторы и I/O для проверки разных сценариев.
Композиция слоёв абстракции — это приём проектирования, когда сложную систему собирают из нескольких «слоёв», каждый из которых решает свою задачу и скрывает детали реализации от слоя выше. Верхний слой пользуется интерфейсом нижнего, не зная, как именно тот устроен.
В Zig это особенно заметно в стандартной библиотеке: она намеренно выстроена как набор слоёв, чтобы дать программисту полный контроль (над памятью, I/O, параллелизмом) без скрытых механизмов.
Допустим, вы хотите просто вывести строку в консоль:
try stdout.writer().writeAll("Hello\n");За этим простым вызовом прячется несколько слоёв:
- Прикладной слой (пользовательский API):
writeAll.
Это то, что видит программист: «запиши все байты». Он не должен думать про буферизацию, аллокации, тип I/O. - Слой буферизации/адаптации:
writer()и внутренние помощники.
Они могут буферизировать вывод, разбивать большие записи на куски, приводить данные к нужному формату. Здесь уже появляется логика «как эффективнее писать». - Слой I/O‑интерфейса: реализация
std.io.Io(например,Threaded,Evented,Uring).
Он определяет, как именно происходит запись: синхронно в поток, через очередь задач, через io_uring и т. п. У него есть единый интерфейс, но разные реализации. - Низкоуровневый слой: системные вызовы ОС (
write,sendmsgи т. д.) или специфичные API (io_uring).
Здесь уже происходит реальный ввод‑вывод.
Каждый слой композирует (собирает) функциональность из нижележащих, добавляя свой уровень удобства или оптимизации.
В Zig почти не используют наследование. Вместо этого применяют:
- Структуры с полями‑интерфейсами: структура хранит поле типа «любой тип, реализующий интерфейс I», и делегирует вызовы ему.
- Функции, принимающие интерфейсы: функция не привязана к конкретной реализации, а работает с любым типом, который удовлетворяет интерфейсу.
Пример упрощённой идеи:
const Writer = struct {
inner: anytype, // любой тип, у которого есть метод write
fn write(self: @This(), data: []const u8) !void {
return self.inner.write(data);
}
};Здесь Writer композирует поведение inner. Это и есть композиция: «я использую другой объект, чтобы делать свою работу».
-
Аллокаторы везде.
Почти любая функция, которая может выделить память, принимаетAllocator. Внутри она может создавать временные структуры, которые сами используют аллокатор, и передавать его дальше вниз по стеку вызовов. Так формируется «цепочка» аллокаторов отmainдо самого низа. -
I/O как интерфейс.
std.ioдаёт общий интерфейс, а разные реализации (потоковая, событийная, io_uring) подставляются вместо него. Функция на верхнем уровне просто вызываетio.write(...)и не знает, что под капотом — обычныйwriteили сложная асинхронная очередь. -
Явная передача контекста.
В Zig 0.16 точка входаmain(init: std.process.Init)получает весь контекст (аллокаторы, I/O, окружение). Этот контекст передаётся вниз по слоям, и каждый слой использует только то, что ему нужно.
Представим, что мы пишем HTTP‑клиент:
- Слой 1 (верхний):
http.request(url, headers)— удобный API. - Слой 2: парсинг URL, формирование запроса, буферизация тела.
- Слой 3: сетевой клиент, который умеет отправлять байты и читать ответы; он принимает аллокатор и I/O.
- Слой 4: сокет/io_uring/event loop — низкоуровневые операции.
Когда вы вызываете http.request, он внутри себя:
- создаёт нужные структуры (используя аллокатор),
- формирует байты запроса,
- передаёт их сетевому слою, который уже знает, как писать в сокет.
Для вас это один вызов, а внутри — композиция нескольких слоёв.
- Контроль: вы всегда видите, где используется аллокатор и какой I/O задействован.
- Тестируемость: можно подменить I/O на «фейковый» для тестов или аллокатор на аллокатор с проверкой утечек.
- Гибкость: один и тот же API работает поверх разных реализаций (потоки, события, io_uring).
- Отсутствие скрытых затрат: нет неявных аллокаций, фоновых потоков или «магии» в рантайме.
То, что кажется «рекурсией» в коде Zig, чаще всего:
- Последовательные вызовы функций (не рекурсия, а просто цепочка).
- Делегирование интерфейсам (вызов метода у внутреннего объекта).
- Циклические зависимости на уровне модулей, которые разрешаются на этапе компиляции (
comptime).
Рекурсивные вызовы функций в Zig встречаются, но это не особенность архитектуры слоёв — это обычный инструмент для работы с древовидными структурами, обходом графов и т. п.
Разберём std.json в Zig (в том числе в версии 0.16) как яркий пример композиции слоёв абстракции: тут очень чётко видно, как Zig собирает сложный функционал из простых, явно контролируемых частей.
std.json.Value— динамическое представление JSON (объект, массив, строка, число и т.д.). Это «данные».std.json.Stringify— сериализация (превращение Zig‑значений в JSON).std.json.Parser/std.json.parseFrom*— парсинг (превращение JSON в Zig‑значения).std.json.Parsed(T)— удобная обёртка, которая держит результат парсинга и управляет памятью (через арену).
И везде — явный аллокатор. Никаких скрытых выделений.
Пример «простого» вызова, который вы видите в коде приложения:
const parsed = try std.json.parseFromSlice(
MyConfig,
gpa,
json_bytes,
.{ .allocate = .alloc_always },
);
defer parsed.deinit();
const config = parsed.value;Для вас это «дай мне конфиг из JSON‑байтов». Всё остальное скрыто за этим вызовом.
Что тут уже проявляется из композиции:
- Вы явно передаёте аллокатор (
gpa). - Получаете
Parsed(MyConfig), у которого естьdeinit()— это значит, что управление памятью инкапсулировано внутри этой структуры. - Опции (
.allocate) позволяют тонко управлять поведением (дублировать строки или ссылаться на исходные байты).
std.json.Parsed(T) — это и есть первый серьёзный слой абстракции. Его идея:
- Внутри он держит
ArenaAllocator(или ссылку на него). - Все аллокации, нужные для представления JSON (строки, массивы, объекты), делаются в этой арене.
- Когда вы вызываете
parsed.deinit(), вся память, занятая под JSON, освобождается одним махом.
Это классический приём для парсеров: не заставлять пользователя вручную освобождать каждое поле, но при этом не прятать аллокации.
Композиция тут такая: Parsed(T) «собирает» в себе:
- значение типа
T(результат парсинга), - арену, которая владеет всей памятью,
- метод
deinit, который всё чистит.
Функции вида parseFromSlice, parseFromTokenSource внутри себя:
- Инициализируют арену (или используют переданную).
- Создают «токенизатор» (читает байты и выдаёт поток токенов:
{,},"key",numberи т.п.). - Запускают собственно парсер, который строит дерево
Valueили сразу мапит в целевой типT.
В Zig это сделано так, чтобы можно было подменять части:
- Есть низкоуровневый API на токенах (
parseFromTokenSource), если вы хотите свой источник токенов (не слайс байтов, а, скажем, сетевой поток). - Есть высокоуровневые обёртки (
parseFromSlice), которые сами создают токенизатор.
То есть композиция: «парсер использует токенизатор», «токенизатор использует источник байтов», «всё это живёт в арене».
Если вы используете parseFromReader (или делаете свой токенизатор поверх Reader), то в игру вступает std.io:
- Источник данных — это любой тип, реализующий интерфейс
std.io.Reader(файл, буфер, сокет, mock‑объект для тестов). - Токенизатор просто вызывает
reader.read()столько раз, сколько нужно. - Никаких предположений о том, откуда пришли байты: это абстракция.
Именно здесь проявляется та самая «вложенность» из вашего первого вопроса:
- Верхний уровень:
parseFromSliceилиparseFromReader. - Средний:
Parser+Tokenizer. - Нижний:
Reader(I/O) +Allocator(память).
Каждый слой передаёт вниз зависимости (аллокатор, reader, опции) и делегирует работу.
С обратной стороны — превращение Zig‑структур в JSON:
var buf = std.ArrayList(u8).init(gpa);
defer buf.deinit();
try std.json.Stringify.fmt(
config,
.{ .whitespace = .indent_2 },
buf.writer(),
);
const json_bytes = buf.items;Слои тут такие:
- Пользовательский API:
Stringify.fmt— «запиши JSON в writer». - Логика сериализации: обход структур, массивов, рекурсивное преобразование полей в JSON‑представление.
- Writer: любой
std.io.Writer(в примере —ArrayList.writer()). - Память:
ArrayListуправляет буфером, аллокации явно черезgpa.
Обратите внимание: рекурсивность здесь — это естественный обход вложенных структур (объект внутри объекта, массив объектов), а не какая‑то особенность Zig.
То, что выглядит как «вложенность/рекурсия» в std.json, чаще всего это:
- Цепочка зависимостей: функция принимает аллокатор и/или reader/writer, передаёт их вниз.
- Делегирование:
Parsedделегирует аллокации арене, парсер делегирует чтение токенов токенизатору, токенизатор делегирует получение байтов reader’у. - Рекурсивный обход структур при сериализации/парсинге — это просто способ обработать вложенные JSON‑объекты.
Никакой «магии»: каждый уровень явно говорит, что ему нужно, и явно использует нижележащие компоненты.
- Контроль памяти: вы всегда видите, какой аллокатор используется, и можете подставить арену, тестовый аллокатор или аллокатор с профилированием.
- Тестируемость:
- Для парсинга можно передавать слайс байтов или mock‑reader.
- Для сериализации — любой writer, включая «ничего не делающий» или проверяющий формат.
- Гибкость источников данных: можно парсить JSON из файла, сети, строки, потока, без переписывания логики парсера.
- Отсутствие скрытых аллокаций: если функция может выделить память, она требует
Allocator.
Допустим, мы читаем JSON‑конфиг из файла:
const file = try fs.cwd().openFile("config.json", .{});
defer file.close();
// Слой I/O: буферизованный reader
var buf: [4096]u8 = undefined;
const reader = file.reader(&buf);
// Слой парсинга: передаём reader и аллокатор
const parsed = try std.json.parseFromReader(
Config,
gpa,
reader,
.{ .allocate = .alloc_if_needed },
);
defer parsed.deinit();
const config = parsed.value;Здесь видно все слои:
- I/O:
file+reader. - Парсинг:
parseFromReader+ токенизатор + парсер. - Память:
gpa+Parsed+ арена. - API:
Configкак целевой тип.
В 0.16 эта картина стала ещё более явной:
- Точка входа
main(init: std.process.Init)даёт вам готовые аллокаторы и I/O. - Вы можете передать их дальше в
std.json(и в другие части std). - Нет глобальных переменных и скрытых потоков: всё передаётся явно, и композиция слоёв становится прозрачной.
Чтобы в Zig понять из документации (и из кода std), какие методы вызывать и что передавать, нужно опираться не на «интуитивные» API, а на несколько жёстких правил языка и на то, как устроена сама документация.
- Сгенерированные docs в репозитории Zig (GitHub) — там часто самые свежие сигнатуры.
- Локально:
zig build docs— даёт HTML‑документацию поstd. - Исходный код
lib/std/**как «документация первого класса — в Zig это нормально: комментарии и сигнатуры функций прямо в.zigфайлах очень информативны. - Примеры в тестах (
testблоки в файлахstd) — это, пожалуй, самый надёжный источник «как реально использовать».
В Zig почти всё явно. Смотри на сигнатуру функции:
fn parseFromSlice(
comptime T: type,
allocator: std.mem.Allocator,
source: []const u8,
options: std.json.ParseOptions,
) !Parsed(T)Из неё сразу понятно:
comptime T: type— это не аргумент рантайма, это параметр времени компиляции. Ты передаёшь тип в угловых скобках (в Zig — черезcomptimeпараметр, в вызове просто указываешь тип первым):std.json.parseFromSlice(MyConfig, gpa, data, .{}).allocator: std.mem.Allocator— обязательно передай аллокатор. Если его нет в сигнатуре, значит, функция вообще не делает аллокаций.source: []const u8— слайс байтов. Тип говорит сам за себя.options: std.json.ParseOptions— структура опций. Часто можно писать.{}для значений по умолчанию.
Правило: если функция может выделить память — в сигнатуре будет Allocator. Если делает I/O — будет какой‑то интерфейс/reader/writer. Никаких сюрпризов.
В std много «семейств» функций с понятными суффиксами:
parseFromSlice— из слайса байтов (самый простой случай).parseFromReader— из любогоReader(файл, сеть и т.п.).parseFromTokenSource— низкоуровневый API, если хочешь свой токенизатор.
Выбирай по источнику данных:
- Есть готовый слайс в памяти →
parseFromSlice. - Данные приходят потоком →
parseFromReader. - Нужна кастомная логика разбиения на токены →
parseFromTokenSource.
То же самое у Stringify:
Stringify.fmt(value, options, writer)— универсальный вариант.- Часто используют
ArrayList(u8).writer()какwriter, если нужно собрать JSON в буфер.
Смотри на тип результата и наличие deinit:
const parsed = try std.json.parseFromSlice(...);
defer parsed.deinit();- Если есть
deinit()— обязательно вызывай (обычно черезdefer). - Если возвращается
Value— у него нетdeinit, память управляется ареной, которую ты передал внутрь. - Если видишь
Parsed(T)— это именно тот тип, который держит арену и требуетdeinit.
Это и есть «композиция»: структура Parsed собирает в себе и значение, и аллокатор (арену), и метод очистки.
- Найди главный экспорт модуля (например,
std.json) и посмотри публичные функции с понятными именами. - Посмотри типы аргументов:
Allocator,Reader,Writer,Options— сразу дают понять, что готовить. - Проверь возвращаемый тип: есть ли
deinit? Это решает вопрос освобождения ресурсов. - Открой тесты в этом файле: там почти всегда есть 2–3 канонических примера использования.
- Если сомневаешься — попробуй собрать пример. Компилятор в Zig очень конкретный: если забыл аллокатор или передал не тот тип — он скажет точно, чего не хватает.
Пример такого «чтения» на std.fs:
const file = try fs.cwd().openFile("config.json", .{});
defer file.close();Разбор:
fs.cwd()— текущий рабочий каталог..openFile(name, flags)— имя и флаги.- Результат —
File, у которого естьclose(). Значит, нуженdefer file.close(). - Дальше из
fileделаютreader()илиwriter()— и передают в другие функции.
Когда видишь длинные цепочки вроде:
try stdout.writer().writeAll("Hello\n");читай их как композицию:
stdout— уже готовый объект..writer()— создаём адаптер‑writer поверх него..writeAll(...)— вызываем метод у writer’а.
В Zig это не магия: .writer() просто возвращает структуру, у которой есть метод writeAll. Ты можешь сохранить её в переменную, если цепочка слишком длинная:
const w = stdout.writer();
try w.writeAll("Hello\n");Допустим, ты видишь в файле lib/std/json.zig:
pub fn parseFromSlice(
comptime T: type,
allocator: mem.Allocator,
source: []const u8,
options: ParseOptions,
) !Parsed(T) {
// ...
}Ты сразу понимаешь:
- Это публичный API (
pub). - Нужен тип
T, аллокатор, слайс, опции. - Возвращает
Parsed(T)→ нуженdefer parsed.deinit().
Дальше смотришь на Parsed:
pub const Parsed = struct {
arena: std.heap.ArenaAllocator,
value: T,
pub fn deinit(self: *Parsed) void {
self.arena.deinit();
}
};И всё встаёт на места: Parsed — это удобная обёртка, которая держит и данные, и арену, и знает, как всё почистить.
Обращай внимание на:
.{}в примерах — это значения по умолчанию для структур опций.comptime— значит, параметр задаётся при компиляции (тип, иногда флаги).!Type— функция может вернуть ошибку, нужно обрабатывать черезtry/catch.anytypeв реализациях — это детали, которые тебя не касаются; смотри на публичные функции с конкретными типами.
В файлах lib/std/*.zig почти в конце идут test блоки. Например, в json.zig ты найдёшь что‑то вроде:
test "parse simple object" {
const allocator = testing.allocator;
const data = \\{"x": 1}\\;
const v = try std.json.parseFromSlice(Value, allocator, data, .{});
defer v.deinit();
// проверки...
}