Это ощущение возникает не из‑за «бесконечной матрёшки» и уж точно не из‑за бага. В 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 — это когда ты не пытаешься сделать «всё в одном», а собираешь нужную функциональность из отдельных кусочков: каждый кусочек решает свою маленькую задачу и умеет работать с любым другим кусочком, который соответствует нужному интерфейсу.
Ниже разберу это на понятных примерах из
std, чтобы было видно: где слой, что он добавляет и как он передаёт работу дальше.Самый простой пример композиции: файл + буферизация + запись
Здесь три слоя:
file(std.fs.File).Это «чистый» доступ к файлу: умеет читать/писать байты, закрывать файл. У него нет буферизации, нет удобных методов вроде
print.buffered = file.reader(&buf).Это структура
BufferedReader, которая хранит внутри себяfileи буфер. Она реализует интерфейс «читатель/писатель», но при этом делает чтение/запись эффективнее: накапливает данные в буфере и делает системные вызовы реже.buffered.writeAll(...).Метод
writeAllуже не обязан быть у самого файла: он появляется в адаптере. Адаптер сам разбивает запись на куски, следит за тем, чтобы всё ушло, и делегирует реальную запись нижележащемуfile.Что такое композиция здесь:
buffered— это не «тот же самый файл», это новая структура, которая содержит файл и добавляет к нему буферизацию. Когда ты вызываешьbuffered.writeAll, она внутри себя вызываетfile.write. Это и есть композиция: «я использую другой объект, чтобы выполнить свою работу».Композиция в
std.io: Writer и удобные методыЧасто видишь:
Разложим по слоям:
stdout— этоFile(или другой низкоуровневый I/O)..writer()— создаётWriter(Context): структуру, которая хранитContext(в данном случаеstdout) и добавляет методыwriteAll,print,printIndentedи т.п..writeAll(...)— метод уWriter. Он внутри себя делает столько вызововcontext.write(...), сколько нужно, чтобы отправить все байты.Композиция тут в том, что
Writerне пытается заново реализовать запись. Он говорит: «Я умею делать удобные вещи, а реальную работу по отправке байтов я делегирую своему внутреннемуcontext».Если развернуть это явно:
Сразу видно: есть отдельный объект-адаптер
w, который «обёртывает»stdout.Композиция в
std.json: токенизатор поверх ReaderВозьмём парсинг JSON из потока:
Какие слои тут участвуют:
reader(любой тип, реализующийstd.io.Reader).Может быть файлом, сетевым сокетом, слайсом в памяти, моком для тестов — Zig не важно. Важно только, что у него есть метод
read.{,},"key",123, и т.д.Он не знает про JSON-структуру, он просто «режет» байты на понятные куски. Внутри он делает
try reader.read(...).Valueили сразу мапит в целевой типT.Он вызывает
tokenizer.nextToken()и на основе токенов строит структуру данных.Parsed(T)держит арену и отвечает за освобождение всей памяти одним вызовомdeinit.Композиция здесь в том, что каждый слой решает свою задачу:
И они соединяются через интерфейсы: токенизатор требует «читателя», парсер требует «токенизатора».
Как в Zig выглядит композиция структур (без наследования)
В языках с классами часто делают наследование:
BufferedFile extends File. В Zig вместо этого — композиция через поля:Что тут важно:
context: Context.self.context.Когда ты пишешь
file.reader(&buf), ты создаёшь именно такой объект: он хранитfileвнутри и добавляет буферизацию сверху.Почему это ощущается как «матрёшка»
Ты видишь цепочки вроде:
или:
И кажется, что «везде один и тот же интерфейс». На самом деле:
read,write) на разных уровнях — это нормально: это один и тот же контракт (интерфейс), но разные реализации.Это не «бесконечный повтор», а многоуровневая сборка: верхний слой даёт удобство, средний — эффективность, нижний — доступ к ОС.
Практический способ видеть композицию в коде Zig
Когда читаешь новый модуль
stdи хочешь понять, где какие слои:anytypeс методомread— значит, она ожидает любой «читатель». Это интерфейс, который можно реализовать и файлом, и буферизированным потоком, и моком..reader(),.writer(),.buffered(). Почти всегда это адаптер — новая структура, которая хранит внутри себя исходный объект.self.inner.read(...)илиself.context.write(...)— это и есть делегирование, то есть композиция.Пример из теста
std.json, где композиция видна явно:Тут чётко видно три уровня:
file— базовый I/O.reader— буфер поверхfile.parseFromReader— парсер поверхreader.Зачем Zig делает композицию такой явной