Skip to content

Instantly share code, notes, and snippets.

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

  • Save alogic0/73a316cd60d8eeb1030e54ac3b4328f3 to your computer and use it in GitHub Desktop.

Select an option

Save alogic0/73a316cd60d8eeb1030e54ac3b4328f3 to your computer and use it in GitHub Desktop.
Zig и цепочка инерфейсов

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

Что именно ты видишь

Типичный паттерн, который считывается как «вызвал интерфейс → внутри ещё интерфейс → снова тот же интерфейс»:

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

Разбор по шагам:

  1. stdout — это std.fs.File.
  2. .writer() — превращает его в std.io.Writer(File): добавляет удобные методы вроде writeAll, print, буферизацию и т.п.
  3. .writeAll(...) — метод у этого Writer.

То есть ты не вызываешь «тот же самый интерфейс» снова и снова. Ты:

  • берёшь базовый объект,
  • оборачиваешь его в адаптер,
  • используешь более удобный API поверх него.

Почему в Zig это выглядит именно так

1. Нет наследования, зато много обёрток

В Zig нет классов и наследования. Вместо этого используют структуры‑адаптеры, которые хранят внутри себя «нижележащий» объект и делегируют ему вызовы.

Пример упрощённой идеи адаптера:

pub fn Writer(comptime Context: type) type {
    return struct {
        context: Context,

        pub fn write(self: @This(), bytes: []const u8) !void {
            return self.context.write(bytes);
        }

        pub fn writeAll(self: @This(), bytes: []const u8) !void {
            // реализация writeAll поверх write
            ...
        }
    };
}

Здесь Writer — это адаптер. Он «притворяется» более удобным интерфейсом, но внутри делегирует всё тому же нижележащему context.

Когда ты делаешь stdout.writer().writeAll(...), ты как раз используешь такой адаптер.

2. Слои абстракции с одинаковым «глаголом»

Интерфейсы в Zig часто имеют одинаковые глаголы: read, write, update, close. Поэтому кажется, что «один и тот же интерфейс повторяется».

Но на деле это разные уровни:

  • File.read(...) — низкоуровневое чтение из файла.
  • BufferedReader.read(...) — буферизированное чтение поверх File.
  • Tokenizer.readToken(...) — чтение токенов поверх BufferedReader.

Каждый слой реализует свой вариант «чтения», но опирается на нижележащий. Отсюда и ощущение «матрёшки».


Пример из std.json, где это особенно заметно

Возьмём парсинг из потока:

const parsed = try std.json.parseFromReader(
    Config,
    gpa,
    reader,
    .{ .allocate = .alloc_if_needed },
);

Что происходит «внутри»:

  1. parseFromReader создаёт Tokenizer, которому передаёт reader: anytype (любой тип с методом read).
  2. Tokenizer внутри себя делает try reader.read(&buf).
  3. Парсер внутри делает const token = try tokenizer.nextToken().
  4. nextToken внутри себя снова делает reader.read(...), если буфер кончился.

Ты видишь цепочку:

  • парсер → токенизатор → reader → ОС.

И на каждом уровне есть «чтение», поэтому кажется, что интерфейс «повторяется». Но это просто один и тот же глагол на разных уровнях абстракции.


Ещё один пример: буферизация поверх файла

var buf: [4096]u8 = undefined;
const reader = file.reader(&buf);  // BufferedReader поверх File
const parsed = std.json.parseFromReader(..., reader, ...);

Теперь цепочка такая:

  • parseFromReaderTokenizerreader.read()BufferedReaderfile.read().

BufferedReader реализует интерфейс «читатель», который ожидает Tokenizer. А сам BufferedReader делегирует реальное чтение File. Это и есть композиция: каждый слой удовлетворяет интерфейсу нижележащего, но добавляет свою логику (буферизацию, токенизацию, парсинг).


Где тут «рекурсивность» и есть ли она вообще

То, что кажется рекурсией, обычно:

  • Делегирование вызовов: self.inner.read(...).
  • Циклическое использование одного и того же интерфейса на разных слоях.
  • Рекурсивный обход данных (например, при сериализации вложенных JSON‑объектов).

Но это не «бесконечный цикл»: каждый вызов идёт на уровень ниже, к более низкоуровневой реализации.

Настоящая рекурсия (функция вызывает сама себя) в Zig встречается, но это отдельный инструмент (для деревьев, графов и т.д.), а не способ построения интерфейсов.


Как перестать путаться и читать такие цепочки

  1. Смотри на типы. Если видишь writer(), reader(), buffered(), это почти всегда создание адаптера.

  2. Не воспринимай цепочку как «повторение», а как «добавление слоя».
    file.reader() = «добавим буферизацию к файлу».
    .writer() = «добавим удобные методы записи к этому читателю/файлу».

  3. Разбивай длинные цепочки на переменные. Это сразу убирает ощущение «матрёшки»:

    const buf: [4096]u8 = undefined;
    const buffered = file.reader(&buf);
    const parsed = try std.json.parseFromReader(Config, gpa, buffered, .{});
  4. Читай тесты в lib/std/ — там эти цепочки уже разбиты и прокомментированы.


Практический совет: как быстро понять, что происходит

Если видишь длинную цепочку и не понимаешь, где «настоящий» вызов:

  1. Найди определение метода (например, writer()).
  2. Посмотри, какой тип он возвращает.
  3. Найди, какие методы есть у этого типа.
  4. Пойми, что он делегирует нижележащему объекту.

Чаще всего оказывается, что:

  • верхний слой — удобство,
  • средний — буферизация/адаптация,
  • нижний — реальный I/O или парсинг.

@alogic0

alogic0 commented Jul 24, 2026

Copy link
Copy Markdown
Author

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

Ниже разберу это на понятных примерах из std, чтобы было видно: где слой, что он добавляет и как он передаёт работу дальше.


Самый простой пример композиции: файл + буферизация + запись

const file = try fs.cwd().openFile("data.txt", .{});
defer file.close();

var buf: [4096]u8 = undefined;
const buffered = file.reader(&buf);

try buffered.writeAll("hello\n");

Здесь три слоя:

  1. Слой 1: базовый ресурсfile (std.fs.File).
    Это «чистый» доступ к файлу: умеет читать/писать байты, закрывать файл. У него нет буферизации, нет удобных методов вроде print.
  2. Слой 2: адаптер-буферизацияbuffered = file.reader(&buf).
    Это структура BufferedReader, которая хранит внутри себя file и буфер. Она реализует интерфейс «читатель/писатель», но при этом делает чтение/запись эффективнее: накапливает данные в буфере и делает системные вызовы реже.
  3. Слой 3: удобный API поверх буфераbuffered.writeAll(...).
    Метод writeAll уже не обязан быть у самого файла: он появляется в адаптере. Адаптер сам разбивает запись на куски, следит за тем, чтобы всё ушло, и делегирует реальную запись нижележащему file.

Что такое композиция здесь:
buffered — это не «тот же самый файл», это новая структура, которая содержит файл и добавляет к нему буферизацию. Когда ты вызываешь buffered.writeAll, она внутри себя вызывает file.write. Это и есть композиция: «я использую другой объект, чтобы выполнить свою работу».


Композиция в std.io: Writer и удобные методы

Часто видишь:

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

Разложим по слоям:

  • stdout — это File (или другой низкоуровневый I/O).
  • .writer() — создаёт Writer(Context): структуру, которая хранит Context (в данном случае stdout) и добавляет методы writeAll, print, printIndented и т.п.
  • .writeAll(...) — метод у Writer. Он внутри себя делает столько вызовов context.write(...), сколько нужно, чтобы отправить все байты.

Композиция тут в том, что Writer не пытается заново реализовать запись. Он говорит: «Я умею делать удобные вещи, а реальную работу по отправке байтов я делегирую своему внутреннему context».

Если развернуть это явно:

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

Сразу видно: есть отдельный объект-адаптер w, который «обёртывает» stdout.


Композиция в std.json: токенизатор поверх Reader

Возьмём парсинг JSON из потока:

const parsed = try std.json.parseFromReader(
    Config,
    gpa,
    reader,
    .{ .allocate = .alloc_if_needed },
);

Какие слои тут участвуют:

  1. Слой 1: источник данныхreader (любой тип, реализующий std.io.Reader).
    Может быть файлом, сетевым сокетом, слайсом в памяти, моком для тестов — Zig не важно. Важно только, что у него есть метод read.
  2. Слой 2: токенизатор — превращает поток байтов в поток токенов: {, }, "key", 123, и т.д.
    Он не знает про JSON-структуру, он просто «режет» байты на понятные куски. Внутри он делает try reader.read(...).
  3. Слой 3: парсер — берёт токены и строит из них дерево Value или сразу мапит в целевой тип T.
    Он вызывает tokenizer.nextToken() и на основе токенов строит структуру данных.
  4. Слой 4: управление памятьюParsed(T) держит арену и отвечает за освобождение всей памяти одним вызовом deinit.

Композиция здесь в том, что каждый слой решает свою задачу:

  • Парсер не знает, откуда берутся байты — он знает только про токены.
  • Токенизатор не знает, что это JSON — он просто разбивает поток.
  • Источник данных не знает про JSON и токены — он просто отдаёт байты по запросу.

И они соединяются через интерфейсы: токенизатор требует «читателя», парсер требует «токенизатора».


Как в Zig выглядит композиция структур (без наследования)

В языках с классами часто делают наследование: BufferedFile extends File. В Zig вместо этого — композиция через поля:

pub fn BufferedReader(comptime Context: type) type {
    return struct {
        context: Context,
        buf: []u8,
        pos: usize,
        end: usize,

        pub fn read(self: *@This(), buf: []u8) !usize {
            // ... логика буферизации ...
            const n = try self.context.read(buf); // делегируем нижележащему объекту
            return n;
        }
    };
}

Что тут важно:

  • Структура явно хранит context: Context.
  • Её методы вызывают методы у self.context.
  • Это и есть композиция: «у меня внутри есть другой объект, я использую его, чтобы делать свою работу».

Когда ты пишешь file.reader(&buf), ты создаёшь именно такой объект: он хранит file внутри и добавляет буферизацию сверху.


Почему это ощущается как «матрёшка»

Ты видишь цепочки вроде:

stdout.writer().writeAll(...)

или:

parseFromReader(..., reader, ...)

И кажется, что «везде один и тот же интерфейс». На самом деле:

  • На каждом уровне интерфейс одинаковый (например, «читатель» или «писатель»), но реализация разная.
  • Каждый слой добавляет свою логику (буферизацию, токенизацию, форматирование), а реальную работу делегирует нижележащему слою.
  • Поэтому ты видишь похожие методы (read, write) на разных уровнях — это нормально: это один и тот же контракт (интерфейс), но разные реализации.

Это не «бесконечный повтор», а многоуровневая сборка: верхний слой даёт удобство, средний — эффективность, нижний — доступ к ОС.


Практический способ видеть композицию в коде Zig

Когда читаешь новый модуль std и хочешь понять, где какие слои:

  1. Смотри на типы аргументов. Если функция принимает anytype с методом read — значит, она ожидает любой «читатель». Это интерфейс, который можно реализовать и файлом, и буферизированным потоком, и моком.
  2. Смотри, что возвращает .reader(), .writer(), .buffered(). Почти всегда это адаптер — новая структура, которая хранит внутри себя исходный объект.
  3. Читай реализацию адаптера. Там почти всегда будет вызов self.inner.read(...) или self.context.write(...) — это и есть делегирование, то есть композиция.
  4. Смотри тесты. В тестах эти цепочки часто уже разбиты по переменным, и сразу видно, какой слой за что отвечает.

Пример из теста std.json, где композиция видна явно:

var buf: [4096]u8 = undefined;
const reader = file.reader(&buf); // BufferedReader поверх File

const parsed = try std.json.parseFromReader(
    Value,
    testing.allocator,
    reader, // передаём BufferedReader как «читатель»
    .{ .allocate = .alloc_always },
);
defer parsed.deinit();

Тут чётко видно три уровня:

  • file — базовый I/O.
  • reader — буфер поверх file.
  • parseFromReader — парсер поверх reader.

Зачем Zig делает композицию такой явной

  • Контроль. Ты всегда видишь, какой аллокатор используется и какой источник данных.
  • Тестируемость. Можно подменить любой слой: вместо файла — слайс, вместо реального I/O — мок, который всегда возвращает ошибку.
  • Гибкость. Один и тот же парсер работает с файлом, сетью, строкой, потому что он зависит только от интерфейса «читатель», а не от конкретной реализации.
  • Отсутствие магии. Нет скрытых аллокаций, потоков или «автоматических» обёрток: если ты видишь адаптер — ты видишь, где и как он создаётся.

@alogic0

alogic0 commented Jul 25, 2026

Copy link
Copy Markdown
Author

В Zig нет встроенных интерфейсов или виртуальных методов, как в некоторых других языках, поэтому разработчик сам решает, когда явно выстроить механизм динамического диспетчера (vtables), а когда обойтись без него. Я бы сформулировала правило так: используйте vtables, когда нужна гибкость на этапе выполнения, а в остальных случаях ищите более эффективные решения. Давайте разберу подробнее и приведу примеры из реальных библиотек. [1](https://learningzig.org/lessons/22-interfaces-and-vtables)[8](https://gist.github.com/alogic0/abd1268647d6725b53d2442af8525f6f)4

Когда использовать vtables?

Vtables (структура из указателей на функции, paired с указателем на контекст *anyopaque) — это инструмент для динамического диспетчера. Его стоит выбрать в таких сценариях:

  • Нужно полиморфизм во время выполнения. Например, вы собираете коллекцию, куда пользователь может добавлять разные типы (разные бэкенды логирования, разные рендереры), и вы не знаете всех типов на этапе компиляции. [1](https://learningzig.org/lessons/22-interfaces-and-vtables)[4](https://www.bradcypert.com/interfaces-in-zig/)
  • Вы пишете библиотеку, которая должна быть расширяемой. Например, вы предоставляете API, где пользователь может подключить свой собственный аллокатор или модуль ввода-вывода — система должна работать с любым типом, реализующим нужный контракт. [4](https://www.bradcypert.com/interfaces-in-zig/)
  • Интерфейс включает много методов. Хранить отдельный указатель на функцию для каждого метода в каждом экземпляре структуры неэффективно по памяти. Vtable позволяет иметь одну общую таблицу для всех экземпляров одного типа. [3](https://medium.com/@trinitietp/interfaces-in-zig-five-patterns-you-should-know-5600acf3cfad)

Примеры из Zig-библиотек:

  • std.mem.Allocator. Это, пожалуй, самый показательный пример. Интерфейс Allocator содержит *anyopaque для контекста и указатель на vtable. Сама vtable — это структура с функциями alloc, resize, free. Разные реализации (ArenaAllocator, FixedBufferAllocator, DebugAllocator) предоставляют свою статическую vtable. Когда вы передаёте аллокатор в функцию, она работает через vtable — не важно, какой именно тип аллокатора был на самом деле. [8](https://gist.github.com/alogic0/abd1268647d6725b53d2442af8525f6f)4
  • std.io.Writer и std.io.Reader. В новых версиях стандартной библиотеки (начиная с Zig 0.15) для потоков ввода-вывода тоже используется подход с vtable. Интерфейс содержит указатель на vtable, где определены методы вроде write, flush, drain. Это позволяет разным структурам (буферизованный писатель, файл и т.п.) реализовывать общий контракт. [6](https://joegm.github.io/blog/inside-zigs-new-writer-interface/)[1](https://learningzig.org/lessons/22-interfaces-and-vtables)

Когда можно обойтись без vtables?

Если задача позволяет решить всё на этапе компиляции, vtables принесут лишнюю нагрузку (косвенный вызов, который может мешать инлайнингу и создавать нагрузку на предсказатель ветвлений CPU). В таких случаях лучше выбрать другие подходы:

  • Comptime duck typing с anytype. Если все конкретные типы известны на этапе компиляции, можно написать функцию, которая принимает параметр типа anytype. Компилятор проверит на этапе компиляции, есть ли у типа нужные методы, и сгенерирует специализированный код для каждого типа. Это даёт нулевую нагрузку на время выполнения. [1](https://learningzig.org/lessons/22-interfaces-and-vtables)2[3](https://medium.com/@trinitietp/interfaces-in-zig-five-patterns-you-should-know-5600acf3cfad)
  • Tagged unions (помеченные объединения). Если набор типов закрыт (вы знаете все варианты заранее и он не будет расширяться), помеченное объединение с исчерпывающим switch — самый безопасный и эффективный вариант. [3](https://medium.com/@trinitietp/interfaces-in-zig-five-patterns-you-should-know-5600acf3cfad)
  • Прямая передача функции. Если поведение определяется одной конкретной функцией, которую можно передать как параметр, нет смысла строить целую систему интерфейсов. [4](https://www.bradcypert.com/interfaces-in-zig/)

Примеры:

  • В коде, где вы пишете утилиту для работы с разными типами коллекций, и все типы коллекций известны в момент написания функции (например, std.ArrayList, std.StringHashMap), часто достаточно использовать anytype и полагаться на компилируемую проверку методов. [3](https://medium.com/@trinitietp/interfaces-in-zig-five-patterns-you-should-know-5600acf3cfad)
  • При реализации алгоритма, который работает только с определённым набором структур (например, разные фигуры — круг, квадрат, треугольник), и вы точно знаете этот набор, помеченное объединение с switch будет эффективнее vtable. [3](https://medium.com/@trinitietp/interfaces-in-zig-five-patterns-you-should-know-5600acf3cfad)

Как принять решение?

Задайте себе несколько вопросов:

  • Будут ли типы известны на этапе компиляции?
  • Нужно ли смешивать разные типы в одной коллекции или структуре?
  • Пишется ли это как часть публичной библиотеки, которую будут расширять пользователи, или как внутренний, закрытый код?

Если на первые два вопроса ответ «нет», скорее всего, vtables — не лучший выбор. [4](https://www.bradcypert.com/interfaces-in-zig/)

Главное в Zig — делать компромиссы явными. Язык не скрывает эти варианты за ключевыми словами, а даёт вам инструменты, чтобы вы выбрали тот, что лучше подходит под конкретную задачу. [3](https://medium.com/@trinitietp/interfaces-in-zig-five-patterns-you-should-know-5600acf3cfad)

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