Это ощущение возникает не из‑за «бесконечной матрёшки» и уж точно не из‑за бага. В Zig это почти всегда композиция одинаковых по смыслу, но разных по реализации интерфейсов — и это намеренный, очень практичный приём.
Типичный паттерн, который считывается как «вызвал интерфейс → внутри ещё интерфейс → снова тот же интерфейс»:
try stdout.writer().writeAll("Hello\n");Разбор по шагам:
stdout— этоstd.fs.File..writer()— превращает его вstd.io.Writer(File): добавляет удобные методы вродеwriteAll,print, буферизацию и т.п..writeAll(...)— метод у этогоWriter.
То есть ты не вызываешь «тот же самый интерфейс» снова и снова. Ты:
- берёшь базовый объект,
- оборачиваешь его в адаптер,
- используешь более удобный API поверх него.
В 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(...), ты как раз используешь такой адаптер.
Интерфейсы в Zig часто имеют одинаковые глаголы: read, write, update, close. Поэтому кажется, что «один и тот же интерфейс повторяется».
Но на деле это разные уровни:
File.read(...)— низкоуровневое чтение из файла.BufferedReader.read(...)— буферизированное чтение поверхFile.Tokenizer.readToken(...)— чтение токенов поверхBufferedReader.
Каждый слой реализует свой вариант «чтения», но опирается на нижележащий. Отсюда и ощущение «матрёшки».
Возьмём парсинг из потока:
const parsed = try std.json.parseFromReader(
Config,
gpa,
reader,
.{ .allocate = .alloc_if_needed },
);Что происходит «внутри»:
parseFromReaderсоздаётTokenizer, которому передаётreader: anytype(любой тип с методомread).Tokenizerвнутри себя делаетtry reader.read(&buf).- Парсер внутри делает
const token = try tokenizer.nextToken(). nextTokenвнутри себя снова делаетreader.read(...), если буфер кончился.
Ты видишь цепочку:
- парсер → токенизатор → reader → ОС.
И на каждом уровне есть «чтение», поэтому кажется, что интерфейс «повторяется». Но это просто один и тот же глагол на разных уровнях абстракции.
var buf: [4096]u8 = undefined;
const reader = file.reader(&buf); // BufferedReader поверх File
const parsed = std.json.parseFromReader(..., reader, ...);Теперь цепочка такая:
parseFromReader→Tokenizer→reader.read()→BufferedReader→file.read().
BufferedReader реализует интерфейс «читатель», который ожидает Tokenizer. А сам BufferedReader делегирует реальное чтение File. Это и есть композиция: каждый слой удовлетворяет интерфейсу нижележащего, но добавляет свою логику (буферизацию, токенизацию, парсинг).
То, что кажется рекурсией, обычно:
- Делегирование вызовов:
self.inner.read(...). - Циклическое использование одного и того же интерфейса на разных слоях.
- Рекурсивный обход данных (например, при сериализации вложенных JSON‑объектов).
Но это не «бесконечный цикл»: каждый вызов идёт на уровень ниже, к более низкоуровневой реализации.
Настоящая рекурсия (функция вызывает сама себя) в Zig встречается, но это отдельный инструмент (для деревьев, графов и т.д.), а не способ построения интерфейсов.
-
Смотри на типы. Если видишь
writer(),reader(),buffered(), это почти всегда создание адаптера. -
Не воспринимай цепочку как «повторение», а как «добавление слоя».
file.reader()= «добавим буферизацию к файлу».
.writer()= «добавим удобные методы записи к этому читателю/файлу». -
Разбивай длинные цепочки на переменные. Это сразу убирает ощущение «матрёшки»:
const buf: [4096]u8 = undefined; const buffered = file.reader(&buf); const parsed = try std.json.parseFromReader(Config, gpa, buffered, .{});
-
Читай тесты в
lib/std/— там эти цепочки уже разбиты и прокомментированы.
Если видишь длинную цепочку и не понимаешь, где «настоящий» вызов:
- Найди определение метода (например,
writer()). - Посмотри, какой тип он возвращает.
- Найди, какие методы есть у этого типа.
- Пойми, что он делегирует нижележащему объекту.
Чаще всего оказывается, что:
- верхний слой — удобство,
- средний — буферизация/адаптация,
- нижний — реальный I/O или парсинг.
В 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/)[4](https://www.bradcypert.com/interfaces-in-zig/)[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)4std.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). В таких случаях лучше выбрать другие подходы:
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)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)