Skip to content

Instantly share code, notes, and snippets.

@alogic0
Created July 22, 2026 22:09
Show Gist options
  • Select an option

  • Save alogic0/0189ac5e8d7f178b2ae8efd441c839b0 to your computer and use it in GitHub Desktop.

Select an option

Save alogic0/0189ac5e8d7f178b2ae8efd441c839b0 to your computer and use it in GitHub Desktop.
Zig и вложенность

В Zig 0.16 нет какой‑то специальной «самовложенности» или рекурсивности как фичи языка — то, что выглядит как вложенность или рекурсия в библиотечных функциях, обычно объясняется тремя вещами:

  • Явной передачей зависимостей (DI).
  • Композицией интерфейсов (особенно I/O и аллокаторов).
  • Природой системных абстракций (например, обёртки над ОС, которые сами используют другие абстракции).

Что изменилось в Zig 0.16 и почему это влияет на вид API

Ключевые изменения в 0.16, которые сильнее всего меняют стиль стандартной библиотеки:

  1. Точка входа стала DI‑контейнером: main теперь принимает std.process.Init, который несёт аллокаторы, I/O‑интерфейс, окружение, preopens и т. д.
  2. I/O сделан интерфейсом: вместо жёстко заданных функций ввода‑вывода теперь используется std.Io с разными реализациями (Threaded, Evented, Uring и т. п.).
  3. Убран глобальный доступ к окружению и другим ресурсам: всё, что раньше можно было достать «из ниоткуда», теперь передаётся явно.

Из‑за этого многие функции теперь принимают дополнительные параметры (аллокатор, 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 и т. п.).

Почему функции «пробрасывают» аллокатор и I/O

Это фундаментальный принцип 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, параллелизмом) без скрытых механизмов.


Как это работает на примере Zig (std.io и аллокаторы)

Допустим, вы хотите просто вывести строку в консоль:

try stdout.writer().writeAll("Hello\n");

За этим простым вызовом прячется несколько слоёв:

  1. Прикладной слой (пользовательский API): writeAll.
    Это то, что видит программист: «запиши все байты». Он не должен думать про буферизацию, аллокации, тип I/O.
  2. Слой буферизации/адаптации: writer() и внутренние помощники.
    Они могут буферизировать вывод, разбивать большие записи на куски, приводить данные к нужному формату. Здесь уже появляется логика «как эффективнее писать».
  3. Слой I/O‑интерфейса: реализация std.io.Io (например, Threaded, Evented, Uring).
    Он определяет, как именно происходит запись: синхронно в поток, через очередь задач, через io_uring и т. п. У него есть единый интерфейс, но разные реализации.
  4. Низкоуровневый слой: системные вызовы ОС (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. Это и есть композиция: «я использую другой объект, чтобы делать свою работу».


Где в Zig 0.16 видна эта композиция

  1. Аллокаторы везде.
    Почти любая функция, которая может выделить память, принимает Allocator. Внутри она может создавать временные структуры, которые сами используют аллокатор, и передавать его дальше вниз по стеку вызовов. Так формируется «цепочка» аллокаторов от main до самого низа.

  2. I/O как интерфейс.
    std.io даёт общий интерфейс, а разные реализации (потоковая, событийная, io_uring) подставляются вместо него. Функция на верхнем уровне просто вызывает io.write(...) и не знает, что под капотом — обычный write или сложная асинхронная очередь.

  3. Явная передача контекста.
    В 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, он внутри себя:

  • создаёт нужные структуры (используя аллокатор),
  • формирует байты запроса,
  • передаёт их сетевому слою, который уже знает, как писать в сокет.

Для вас это один вызов, а внутри — композиция нескольких слоёв.


Зачем Zig делает это так явно

  • Контроль: вы всегда видите, где используется аллокатор и какой I/O задействован.
  • Тестируемость: можно подменить I/O на «фейковый» для тестов или аллокатор на аллокатор с проверкой утечек.
  • Гибкость: один и тот же API работает поверх разных реализаций (потоки, события, io_uring).
  • Отсутствие скрытых затрат: нет неявных аллокаций, фоновых потоков или «магии» в рантайме.

Важное уточнение про «рекурсивность»

То, что кажется «рекурсией» в коде Zig, чаще всего:

  • Последовательные вызовы функций (не рекурсия, а просто цепочка).
  • Делегирование интерфейсам (вызов метода у внутреннего объекта).
  • Циклические зависимости на уровне модулей, которые разрешаются на этапе компиляции (comptime).

Рекурсивные вызовы функций в Zig встречаются, но это не особенность архитектуры слоёв — это обычный инструмент для работы с древовидными структурами, обходом графов и т. п.

Разберём std.json в Zig (в том числе в версии 0.16) как яркий пример композиции слоёв абстракции: тут очень чётко видно, как Zig собирает сложный функционал из простых, явно контролируемых частей.


Главные сущности в std.json

  • std.json.Value — динамическое представление JSON (объект, массив, строка, число и т.д.). Это «данные».
  • std.json.Stringify — сериализация (превращение Zig‑значений в JSON).
  • std.json.Parser / std.json.parseFrom* — парсинг (превращение JSON в Zig‑значения).
  • std.json.Parsed(T) — удобная обёртка, которая держит результат парсинга и управляет памятью (через арену).

И везде — явный аллокатор. Никаких скрытых выделений.


Слой 1: пользовательский API (самый верхний)

Пример «простого» вызова, который вы видите в коде приложения:

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) позволяют тонко управлять поведением (дублировать строки или ссылаться на исходные байты).

Слой 2: управление памятью и временем жизни (Parsed(T))

std.json.Parsed(T) — это и есть первый серьёзный слой абстракции. Его идея:

  • Внутри он держит ArenaAllocator (или ссылку на него).
  • Все аллокации, нужные для представления JSON (строки, массивы, объекты), делаются в этой арене.
  • Когда вы вызываете parsed.deinit(), вся память, занятая под JSON, освобождается одним махом.

Это классический приём для парсеров: не заставлять пользователя вручную освобождать каждое поле, но при этом не прятать аллокации.

Композиция тут такая: Parsed(T) «собирает» в себе:

  • значение типа T (результат парсинга),
  • арену, которая владеет всей памятью,
  • метод deinit, который всё чистит.

Слой 3: парсер и токенизатор (логика разбора JSON)

Функции вида parseFromSlice, parseFromTokenSource внутри себя:

  1. Инициализируют арену (или используют переданную).
  2. Создают «токенизатор» (читает байты и выдаёт поток токенов: {, }, "key", number и т.п.).
  3. Запускают собственно парсер, который строит дерево Value или сразу мапит в целевой тип T.

В Zig это сделано так, чтобы можно было подменять части:

  • Есть низкоуровневый API на токенах (parseFromTokenSource), если вы хотите свой источник токенов (не слайс байтов, а, скажем, сетевой поток).
  • Есть высокоуровневые обёртки (parseFromSlice), которые сами создают токенизатор.

То есть композиция: «парсер использует токенизатор», «токенизатор использует источник байтов», «всё это живёт в арене».


Слой 4: источник данных и I/O (нижний уровень)

Если вы используете parseFromReader (или делаете свой токенизатор поверх Reader), то в игру вступает std.io:

  • Источник данных — это любой тип, реализующий интерфейс std.io.Reader (файл, буфер, сокет, mock‑объект для тестов).
  • Токенизатор просто вызывает reader.read() столько раз, сколько нужно.
  • Никаких предположений о том, откуда пришли байты: это абстракция.

Именно здесь проявляется та самая «вложенность» из вашего первого вопроса:

  • Верхний уровень: parseFromSlice или parseFromReader.
  • Средний: Parser + Tokenizer.
  • Нижний: Reader (I/O) + Allocator (память).

Каждый слой передаёт вниз зависимости (аллокатор, reader, опции) и делегирует работу.


Сериализация: std.json.Stringify

С обратной стороны — превращение 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;

Слои тут такие:

  1. Пользовательский API: Stringify.fmt — «запиши JSON в writer».
  2. Логика сериализации: обход структур, массивов, рекурсивное преобразование полей в JSON‑представление.
  3. Writer: любой std.io.Writer (в примере — ArrayList.writer()).
  4. Память: ArrayList управляет буфером, аллокации явно через gpa.

Обратите внимание: рекурсивность здесь — это естественный обход вложенных структур (объект внутри объекта, массив объектов), а не какая‑то особенность Zig.


Где тут «самовложенность», которую вы замечали

То, что выглядит как «вложенность/рекурсия» в std.json, чаще всего это:

  • Цепочка зависимостей: функция принимает аллокатор и/или reader/writer, передаёт их вниз.
  • Делегирование: Parsed делегирует аллокации арене, парсер делегирует чтение токенов токенизатору, токенизатор делегирует получение байтов reader’у.
  • Рекурсивный обход структур при сериализации/парсинге — это просто способ обработать вложенные JSON‑объекты.

Никакой «магии»: каждый уровень явно говорит, что ему нужно, и явно использует нижележащие компоненты.


Практические выгоды такой композиции

  1. Контроль памяти: вы всегда видите, какой аллокатор используется, и можете подставить арену, тестовый аллокатор или аллокатор с профилированием.
  2. Тестируемость:
    • Для парсинга можно передавать слайс байтов или mock‑reader.
    • Для сериализации — любой writer, включая «ничего не делающий» или проверяющий формат.
  3. Гибкость источников данных: можно парсить JSON из файла, сети, строки, потока, без переписывания логики парсера.
  4. Отсутствие скрытых аллокаций: если функция может выделить память, она требует 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 как целевой тип.

Как это связано с Zig 0.16 и DI

В 0.16 эта картина стала ещё более явной:

  • Точка входа main(init: std.process.Init) даёт вам готовые аллокаторы и I/O.
  • Вы можете передать их дальше в std.json (и в другие части std).
  • Нет глобальных переменных и скрытых потоков: всё передаётся явно, и композиция слоёв становится прозрачной.

Чтобы в Zig понять из документации (и из кода std), какие методы вызывать и что передавать, нужно опираться не на «интуитивные» API, а на несколько жёстких правил языка и на то, как устроена сама документация.

Где смотреть документацию

  1. Сгенерированные docs в репозитории Zig (GitHub) — там часто самые свежие сигнатуры.
  2. Локально: zig build docs — даёт HTML‑документацию по std.
  3. Исходный код lib/std/** как «документация первого класса — в Zig это нормально: комментарии и сигнатуры функций прямо в .zig файлах очень информативны.
  4. Примеры в тестах (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

  1. Найди главный экспорт модуля (например, std.json) и посмотри публичные функции с понятными именами.
  2. Посмотри типы аргументов: Allocator, Reader, Writer, Options — сразу дают понять, что готовить.
  3. Проверь возвращаемый тип: есть ли deinit? Это решает вопрос освобождения ресурсов.
  4. Открой тесты в этом файле: там почти всегда есть 2–3 канонических примера использования.
  5. Если сомневаешься — попробуй собрать пример. Компилятор в 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");

читай их как композицию:

  1. stdout — уже готовый объект.
  2. .writer() — создаём адаптер‑writer поверх него.
  3. .writeAll(...) — вызываем метод у writer’а.

В Zig это не магия: .writer() просто возвращает структуру, у которой есть метод writeAll. Ты можешь сохранить её в переменную, если цепочка слишком длинная:

const w = stdout.writer();
try w.writeAll("Hello\n");

Конкретный пример разбора std.json по документации/коду

Допустим, ты видишь в файле 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 — это удобная обёртка, которая держит и данные, и арену, и знает, как всё почистить.


Частые подсказки в коде и комментариях Zig

Обращай внимание на:

  • .{} в примерах — это значения по умолчанию для структур опций.
  • 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();
    // проверки...
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment