Вводная

За последние семь лет разработке на 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.