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 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