Вводная
За последние семь лет разработке на Rust, в основном в open source проектах. Отношусь к функциональному стилю: чистые функции, выразительные типы. Всегда интересовался другими языками, а Zig давно привлекал внимание как кандидат на преемника C — низкоуровневый, лёгкий, и постепенно завоёвывающий уважение. Опыт с C в начале карьеры сделал сравнение привлекательным.
Важное уточнение: опыт с Zig начинается с конкретного проекта. Некоторые наблюдения покажутся очевидными для тех, кто работает с Zig ежедневно. Многие решения формировались привычками из Rust, а не глубоким пониманием Zig idioms. Это нормально — все с чего-то начинают, и опираюсь на интуицию, накопленную над годами.
Чтобы сравнение было честным, переимплементировал то, что уже написал на Rust — не toy project, но и не гигантский, и главное, что может быть полезно сообществу. Выбор пал на JSONPath: язык запросов для JSON, описанный в RFC 9535. Версия на Rust уже существовала (jsonpath-rust), цель — то же самое на Zig: zig-jsonpath.
Поддержка IDE
Первое, что удивило — и кто бы ожидал, что это станет запоминающейся частью — поддержка IDE. Точнее, почти полное её отсутствие. RustRover для Rust, различные JetBrains для других языков, а Zig предлагал лишь подсветку синтаксиса и базовое автодополнение. Неудивительно, но пришлось вернуться к истокам: учиться работать с языком в основном из CLI. То, что казалось недостатком, оказалось одной из интересных частей опыта. Оказалось, легко забыть, насколько прямолинейно может быть работать с простыми инструментами командной строки.
Первый реальный урок — build.zig, управляющий процессом с удивительной лёгкостью. Остановился на такой схеме:
zig build test # run all tests
zig build test -Dfilter="filter match function basic" # run one test
zig build test -Ddebug-query=true # all tests with debug
zig build compliance # compliance suite
zig build check # unit tests + compliance
Когда привыкаешь, работать становится действительно приятно.
Zig стал причиной цепной реакции: переход с полноценной IDE к helix + alacritty + zellij.
Плоская структура
В Rust и большинстве других языков долго ищу баланс между размером файлов и глубиной папок. Можно фрагментировать файлы, углублять иерархию на столько, на сколько хватит терпения. Zig тоже это позволяет, но как-то не поощряет (как и C — не удивительно для низкоуровневого системного языка). Вложить файлы и папки можно, но это привносит friction при импортах. Реальный вопрос: зачем? Что реально выигрываешь в читаемости, раскидывая всё по большему количеству файлов и папок? В теории — лучше читается. На практике, когда складываешь связанное в один файл побольше, просто навигируешь по секциям, и есть реальная выгода в том, чтобы всё было в одном месте. Zig мягко подталкивает к плоской структуре. Если модели нужен companion — создаю файл model_<companion> рядом и двигаюсь дальше.
Не думаю, что это масштабируется на большие проекты — в какой-то момент нужна реальная иерархия. Но порог для этого в Zig оказался намного выше, чем ожидал. В Rust сразу тянусь к структуре папок, почти по умолчанию. В Zig постоянно откладывал, и в итоге так её и не создал.
Контраст оказался полезен не только для Zig — заставил пересмотреть и в других языках, организую ли я файлы потому, что проект этого требует, или просто из привычки. Это честный способ оценить размер проекта: если в первый день не удержишься от создания папок — может быть, проект меньше, чем кажется.
Реальное различие, рядом:
Rust (src/):
src/
├── lib.rs
├── parser.rs
├── parser/
│ ├── errors.rs
│ ├── macros.rs
│ ├── model.rs
│ ├── tests.rs
│ └── grammar/
│ └── json_path_9535.pest
├── query.rs
└── query/
├── atom.rs
├── comparable.rs
├── comparison.rs
├── filter.rs
├── jp_query.rs
├── queryable.rs
├── segment.rs
├── selector.rs
├── state.rs
├── test.rs
└── test_function.rs
Zig (src/):
src/
├── root.zig
├── parser.zig
├── model.zig
├── model_query.zig
└── query.zig
Тесты
Отложив compliance suite RFC 9535 в сторону, сосредоточусь на языке как таком:
В Rust придерживаюсь двух подходов к тестированию:
- Inline unit tests в том же файле или папке, что и код, который они проверяют. Удобный default, всегда под рукой, без подготовки.
- Integration tests в отдельной папке (например,
tests) за пределами основного дерева. Это исключение, а не правило, и иногда вообще отсутствует.
Ожидал примерно то же самое от Zig. На бумаге выглядит похоже: тесты прямо в файле. Проблема, по крайней мере для меня, в многословности. Учитывая плоскую структуру, остаются два варианта: отдельный model_test файл на каждую модель или тесты встроенные прямо в файл модели. Оба захламляли: либо отдельные файлы, либо папку целиком.
Выбрал второй вариант, что потребовало явной настройки в build.zig. Когда всё подключил, сработало хорошо и оставалось чистым.
В целом: писать и управлять тестами в Rust проще. Но в Zig большая часть friction'а — язык-специфична, связана с ручным управлением памятью, а не самой инфраструктурой тестирования.
Без функционального парадигма
Rust технически императивный язык, но сильно опирается на функциональные концепции: zero-cost iterators, ленивые вычисления, ADTs, pattern matching, monadic types, traits, closures и так далее. Потратив время на Haskell и Erlang, предпочитаю функциональный стиль, и это видно в библиотеке. Она опирается на FP idioms:
- Monadic error control через combinators вроде
Queryableи related types - Monadic-style data types вроде
Data<T>сmap,flat_map,reduceи друзьями - Pure, immutable transformations
- Combinators над iterators вместо loops
- Closures для локальной абстракции
- Declarative macros как небольшой embedded DSL
- Sum types и product types
Входил с надеждой сохранить хотя бы основные концепции. На практике, где Rust опирается на immutability и combinators, Zig толкал к in-place mutation и паттернам, наиболее естественным для императивного мира.
Где языки остаются близки: sum types.
Чистый и прямой в Rust:
pub trait Query {
fn process<'a, T: Queryable>(&self, state: State<'a, T>) -> State<'a, T>;
}
impl Query for Segment {
fn process<'a, T: Queryable>(&self, step: State<'a, T>) -> State<'a, T> {
match self {
Segment::Descendant(segment) => segment.process(step.flat_map(process_descendant)),
Segment::Selector(selector) => selector.process(step),
Segment::Selectors(selectors) => process_selectors(step, selectors),
}
}
}
Duck-typed в Zig:
pub fn query(node: anytype, iteration: *JsonPathIter) !void {
const T = switch (@typeInfo(@TypeOf(node))) {
.pointer => |p| p.child,
else => @TypeOf(node),
};
if (!@hasDecl(T, "query")) {
return; // no compile-time trait; just checks the method exists
}
try node.query(iteration);
}
Рекурсия держится на обеих сторонах.
Rust:
fn process_descendant<T: Queryable>(data: Pointer<T>) -> Data<T> {
if let Some(array) = data.inner.as_array() {
Data::Ref(data.clone()).reduce(
Data::new_refs(/* children */).flat_map(process_descendant)
)
} else { Data::Nothing }
}
Zig:
fn collectDescendants(allocator, value: *std.json.Value, path, out) !void {
try out.append(allocator, .{ .json = value, .path = try allocator.dupe(u8, path) });
switch (value.*) {
.array => |arr| for (arr.items) |*elem| try collectDescendants(allocator, elem, child_path, out),
else => {},
}
}
Но язык быстро толкает к отходу от функционального стиля, главным образом потому, что приходится работать с allocators напрямую. Чисто функциональный подход означает постоянное создание новых структур. Это либо дорого по памяти, либо дорого по ручной бухгалтерии, нужной, чтобы этого избежать.
Mutation vs. immutable monad — основное различие.
Rust делает straightforward monadic transformation:
pub fn flat_map<F>(self, f: F) -> Data<'a, T> {
match self {
Data::Ref(data) => f(data), // returns a *new* Data
Data::Refs(v) => Data::Refs(v.into_iter().flat_map(...).collect()),
_ => Data::Nothing,
}
}
Zig переходит на mutation:
pub fn queryName(name: []const u8, iteration: *q.JsonPathIter) !void {
while (i < iteration.cursors.items.len) {
if (obj.getPtr(name)) |val| {
iteration.cursors.items[i] = .{ .json = val, .path = new_path }; // in-place overwrite
} else iteration.remove(i); // mutate list directly
}
}
Reduce vs Fork.
Rust:
selectors.iter().map(|s| s.process(step.clone())).reduce(State::reduce)
Zig:
var lhs_branch = try iter.fork(); // deep copy of cursor state
defer lhs_branch.deinit(); // then discarded
Combinators vs. loops.
Rust:
items.iter().enumerate().filter(|(_, i)| cond(i)).map(|(idx, i)| Pointer::idx(i, path, idx)).collect()
Zig:
while (i < cursors.len) {
if (actual_index < arr.items.len) { cursors[i] = .{...}; i += 1; }
else iteration.remove(i);
}
В целом это отражает цели обоих языков и целевую область, разумный компромисс. Субъективно, код на Zig оказался менее читаемым, чем его аналог на Rust.
Allocators
Allocators повсюду. Почти каждая функция их принимает, каждая структура их держит. Явно, и когда привыкаешь, относительно straightforward следить. Это в сущности определяющая черта языка, так что не скажу, что не предупреждали.
На практике процесс нудный. Нужно тщательно следовать convention init/deinit, и дисциплина шатается, когда стек вызовов растёт. Явно лучше, чем segfault или corrupted memory на C, но против Rust — по-прежнему нужно обеспечивать правило вручную: выделить, обработать path ошибки, решить, кто ответственен за deinit, каждый раз.
К счастью, TestAllocator спешит на помощь. Он не поймает всё автоматически, нужно писать тесты, упражняющие failure paths — но как только напишешь, довольно надёжно работает. И это ловушка: выглядит очевидно на бумаге, до момента, когда код усложняется, тогда эти баги запутываются и прячутся.
Вот случаи, что ударили больнее всего, каждый в сравнении с тем, как Rust решает ту же форму:
Memory leak: забытый deinit
var iter = q.JsonPathIter.init(&root, std.testing.allocator);
try iter.append(&root, "$['a']");
// BUG: no iter.deinit()
Поймано: MemoryLeakDetected, указывающий на dupe вызов внутри append.
Исправление: defer iter.deinit(); сразу после init.
Rust: Drop запускается автоматически в конце scope, так что этот конкретный баг просто не существует. Технически утечки всё ещё возможны в Rust, вроде Rc reference cycles или явного Box::leak, так что "никогда не течёт" не guarantee, просто что-то, на что нужно потратить усилия.
Memory leak: deinit пропущен на error path
fn build(json: *Value, a: Allocator) !q.JsonPathIter {
var iter = q.JsonPathIter.init(json, a);
try iter.append(json, "$['a']"); // ok
try iter.append(json, "$['b']"); // fails -> iter leaked
return iter;
}
Поймано: FailingAllocator{ .fail_index = 1 }, что заставляет второй append в MemoryLeakDetected.
Исправление: errdefer iter.deinit(); сразу после init.
Rust: поистине устранено. Drop::drop срабатывает безусловно при любом выходе из scope, включая ранний return из ?.
Memory corruption: deinit вызван дважды
fn runQuery(json: *Value, qstr: []const u8, a: Allocator) !q.JsonPathResult {
var iter = q.JsonPathIter.init(json, a);
errdefer iter.deinit();
try q.query(qstr, &iter);
return iter.toResult(parsed); // ownership moves to caller
}
fn cacheAndLog(json: *Value, qstr: []const u8, a: Allocator, cache: *std.ArrayList(q.JsonPathResult)) !void {
var result = try runQuery(json, qstr, a);
try cache.append(result); // cache now holds a (shallow) copy of result's pointers
defer result.deinit(); // BUG: frees the same heap data cache.items still points to
printResults(&result);
}
fn processAll(json: *Value, queries: [][]const u8, a: Allocator) !void {
var cache = std.ArrayList(q.JsonPathResult).init(a);
defer {
for (cache.items) |*r| r.deinit(); // frees the SAME memory Layer 2 already freed
cache.deinit();
}
for (queries) |qs| try cacheAndLog(json, qs, a, &cache);
}
Поймано: под std.testing.allocator, что падает на cache.items[0].deinit() второго запроса во время cleanup processAll, DoubleFree указывает на обе точки free, подтверждая, что это bug владения между функциями, а не однострочная опечатка.
Исправление: только один слой может владеть значением. Так как cache живёт дольше cacheAndLog, владение принадлежит слою три; слой два не должен defer deinit после передачи:
fn cacheAndLog(json: *Value, qstr: []const u8, a: Allocator,
cache: *std.ArrayList(q.JsonPathResult)) !void {
var result = try runQuery(json, qstr, a);
printResults(&result); // use it first
try cache.append(result); // then hand off ownership — no defer after this
}
Rust: эта точная форма не скомпилируется. cache.push(result) moves result — после этой строки result больше не существует как usable binding, так что нет способа позже случайно вызвать drop(result).
Memory corruption: orphaned allocation при перемещении в структуру когда оно не удаётся
pub fn appendBuggy(self: *Iter, v: *Value, path: []const u8) !void {
const duped = try self.allocator.dupe(u8, path);
// BUG: no errdefer
try self.cursors.append(self.allocator, .{ .json = v, .path = duped });
}
Поймано: FailingAllocator{ .fail_index = 1 } что падает при росте array'я (второе выделение), orphaning duped (первое выделение).
Это течёт уникальным способом от case one: iter.deinit() работает хорошо, просто никогда не увидит эту конкретную строку.
Исправление:
const duped = try self.allocator.dupe(u8, path);
errdefer self.allocator.free(duped); // only fires if append below fails
try self.cursors.append(self.allocator, .{ .json v, .path = duped });
Rust: истинно по конструкции. Vec::push(item) moves item и либо успешен, либо прерывается на OOM и нет fallible push в стандартной API, что возвращает вам "allocated but unlinked" значение, что можно случайно потерять. Зазор, что errdefer заполняет здесь, просто не существует с самого начала.
Библиотеки и core API
Экосистема ещё очень молодая. Реальная нехватка библиотек, даже что-то базовое как regex не в полной мере mature — например, mvzr, regex engine доступный в Zig, не поддерживает Unicode property escapes (\p{...}), что всплыло прямо как пробел при реализации фильтра RFC 9535. Кроме того, сама standard library языка меняет API от версии к версии. Ничто из этого не было удивительно входя, но стоит отметить для истории.
Общее впечатление
Язык отличается от Rust (кто бы мог подумать), но оставил genuinely хорошее впечатление. Straightforward, современный, и blazingly fast. Верю, что язык имеет реальный потенциал стать истинным преемником C. С другой стороны, он ещё молод, и это видно: форма языка кажется незаконченной в местах, и подозреваю, что перберёт больше из крутых quality-of-life features и синтаксического сахара, когда вырастет.
Для себя хочу продолжить вносить вклад в экосистему, когда встречу стоящий проект.
Ссылки
Дисклеймер: стиль и error handling по всей статье были очищены с помощью AI.