Hoe Zig aanvoelt voor iemand die uit Rust komt

Maar ik ben altijd nieuwsgierig naar andere talen, en Zig stond al een tijdje op mijn radar als een kandidaat-opvolger voor C: lower-level, lichter en gestaag zijn plek aan het veroveren als een taal die serieus wordt genomen. Omdat ik aan het begin van mijn carrière tijd heb besteed aan C, voelde de vergelijking altijd interessant.

Eén kanttekening vooraf: mijn ervaring met Zig begint bij dit project. Sommige observaties zullen naïef en voor de hand liggend overkomen voor mensen die dagelijks met Zig werken, en sommige beslissingen die ik heb genomen waren vrijwel zeker niet optimaal; ze werden meer gevormd door gewoontes uit Rust dan door diepe Zig-idiomen. Dat is prima, iedereen moet ergens beginnen. In de tussentijd vertrouw ik op de cross-language intuïtie die ik in de loop der jaren heb opgebouwd, voor beter of slechter.

Om de vergelijking eerlijk te maken, besloot ik iets te reimplementeren dat ik al in Rust had gebouwd. Niet een simpel speeltje, maar ook geen enorm project; idealiter iets waar de community daadwerkelijk gebruik van kan maken. Ik heb gekozen voor JSONPath: een querytaal voor JSON, gespecificeerd in RFC 9535. De Rust-versie bestond al (jsonpath-rust), en het doel was om hetzelfde naar Zig te brengen: zig-jsonpath.

IDE-ondersteuning

Het eerste dat me overviel — en eerlijk gezegd, wie had verwacht dat dit het meest memorabele deel zou zijn — was de IDE-ondersteuning, of liever gezegd, het bijna totale gebrek daaraan. Ik gebruikte RustRover voor Rust en diverse JetBrains-varianten voor andere talen. Zig bood in vergelijking weinig meer dan syntax-highlighting en basis-autocompletion.

Het was niet direct verrassend, maar het dwong me terug naar de basis: leren werken met de taal hoofdzakelijk vanuit de command line. Wat begon als een nadeel, werd een van de interessantste delen van de ervaring. Het bleek dat ik simpelweg was vergeten hoe rechttoe rechtaan het kan zijn om te vertrouwen op bare CLI-tooling.

De eerste echte les hier was build.zig, dat dit met verrassende eenvoud afhandelt. Uiteindelijk ben ik op deze setup uitgekomen:

zig build test                                              # Voer alle tests uit
zig build test -Dfilter="filter match function basic"       # Voer één specifieke test uit
zig build test -Ddebug-query=true                           # Alle tests met debug-modus
zig build compliance                                        # Compliance suite
zig build check                                             # Unit tests + compliance

Zodra je deze manier van werken accepteert, is het oprecht verfrissend. Indirect heeft Zig een grotere kettingreactie teweeggebracht: mijn overstap van een volledige IDE naar een setup met Helix + Alacritty + Zellij.

Platte structuur

Bij Rust, en de meeste andere talen, besteed ik altijd veel tijd aan het zoeken naar de juiste balans tussen bestandsgrootte en mapdiepte. Je bent vrij om bestanden te fragmenteren en de maphiërarchie zo diep te maken als je wilt. Zig kan hier ook mee om, maar moedigt het niet echt aan (net als C, wat geen verrassing is voor een low-level systeemtaal).

Je kunt bestanden en mappen nesten als je wilt, maar dat brengt wat frictie met zich mee bij het importeren. De vraag wordt dan: waarom zou je dat doen? Wat win je aan leesbaarheid door alles over meer bestanden en mappen te verdelen? In theorie is de leesbaarheid beter. In de praktijk, wanneer je gerelateerde zaken samenvoegt in één groter bestand, kun je het simpelweg opsplitsen en sectie voor sectie navigeren. Er is een echt voordeel aan het hebben van alles op één plek.

Over het algemeen duwt Zig je richting een platte structuur. Als iets een companion nodig heeft voor een model, maak ik gewoon een model_<companion> bestand ernaast aan en ga ik verder. Ik denk niet dat dit schaalt naar zeer grote projecten — op een gegeven moment heb je een echte hiërarchie nodig — maar de drempel waarvoor een hiërarchie nodig is, bleek in Zig veel hoger dan ik verwachtte.

In Rust grijp ik bijna standaard vroegtijdig naar een mapstructuur. In Zig bleef ik dit uitstellen, en aan het einde van dit project had ik het helemaal niet nodig. Dat contrast was nuttig, omdat het me deed heroverwegen of ik in andere talen bestanden organiseer omdat het project dat echt nodig heeft, of simpelweg uit gewoonte.

Hier is het feitelijke verschil, naast elkaar:

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

Tests

Als we de RFC 9535 compliance suite even buiten beschouwing laten en ons puur op de taal richten: in Rust houd ik me meestal aan twee benaderingen voor testen:

  1. Inline unit tests, die in hetzelfde bestand of dezelfde map staan als de code die ze dekken. Dit is de handige standaard.
  2. Integratietests, in een onafhankelijke map (zoals tests) buiten de hoofdbroncode. Dit is de uitzondering, niet de standaard.

Ik verwachtte ongeveer dezelfde verdeling van Zig. Op papier ziet het er vergelijkbaar uit: je kunt tests direct in hetzelfde bestand schrijven. Het probleem was voor mij echter de redundantie (verbosity). Gegeven de platte structuur waar ik voor had gekozen, hield ik twee opties over: ofwel een apart model_test bestand per model, of tests direct inline in het modelbestand. Beide benaderingen zorgden voor clutter: ofwel in de individuele bestanden, ofwel in de hoofdmap.

Ik koos voor de tweede optie, wat betekende dat ik het expliciet moest configureren in build.zig. Eenmaal ingesteld werkte het goed en bleef het overzichtelijk.

Over het algemeen voelt het schrijven en beheren van tests in Rust gemakkelijker. In het geval van Zig komt een groot deel van die extra frictie echter voort uit de taal zelf; het heeft te maken met het handmatige geheugenbeheer van Zig in plaats van de testinfrastructuur.

Geen functioneel paradigma

Rust is technisch gezien een imperatieve taal, maar leunt zwaar op functionele concepten: zero-cost iterators, lazy evaluation, ADTs, pattern matching, monadische types, traits, closures, enzovoort. Omdat ik ook tijd heb besteed aan Haskell en Erlang, ben ik sterk geneigd naar een functionele stijl, en dat is terug te zien in deze bibliotheek. Deze leunt zwaar op FP-idiomen:

  • Monadische foutcontrole via combinators zoals Queryable en gerelateerde types.
  • Monadische datatypes zoals Data<T> met map, flat_map, reduce en vrienden.
  • Pure, immutable transformaties.
  • Combinators over iterators in plaats van loops.
  • Closures voor lokale abstractie.
  • Declaratieve macro's als een kleine embedded DSL.
  • Sum types en product types.

Ik wist dat ik dit niet allemaal naar Zig kon overbrengen, maar ik hoopte in ieder geval de kernconcepten te behouden. In de praktijk, waar Rust leunt op immutability en combinators, duwde Zig me richting in-place mutatie en het patroon dat het meest natuurlijk is in de imperatieve wereld.

Waar de twee dicht bij elkaar blijven, zijn de sum types.

Puur en direct in 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 in 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; // geen compile-time trait; controleert alleen of de methode bestaat
    }
    try node.query(iteration);
}

Recursie blijft aan beide kanten overeind.

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 => {},
    }
}

Maar de taal dwingt je snel om af te wijken van de functionele stijl, vooral omdat je nu direct met allocators werkt. Een echt pure functionele benadering zou betekenen dat er constant nieuwe structuren worden aangemaakt. Dat is ofwel duur in termen van geheugen, of duur in de handmatige administratie die nodig is om dat te voorkomen.

Het fundamentele verschil is mutatie versus de immutable monad.

Rust doet een eenvoudige monadische transformatie:

pub fn flat_map<F>(self, f: F) -> Data<'a, T> {
    match self {
        Data::Ref(data) => f(data),      // retourneert een *nieuwe* Data
        Data::Refs(v) => Data::Refs(v.into_iter().flat_map(...).collect()),
        _ => Data::Nothing,
    }
}

Zig stapt over op mutatie:

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);                                          // muteer lijst direct
    }
}

Reduce versus Fork.

Rust:

selectors.iter().map(|s| s.process(step.clone())).reduce(State::reduce)

Zig:

var lhs_branch = try iter.fork();   // deep copy van cursor state
defer lhs_branch.deinit();          // daarna weggegooid

Combinators versus 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);
}

Al met al weerspiegelt dit de ontwerpdoelen en het doeldomein van elke taal. Het is een redelijke afweging, maar subjectief vond ik de resulterende Zig-code minder leesbaar dan de Rust-tegenhanger.

Allocators

Allocators zijn overal. Bijna elke functie accepteert er een; elke structuur bevat er een. Het is expliciet, en zodra je dat accepteert als de 'toegangsprijs', is het relatief eenvoudig te volgen. Dit is min of meer het kenmerkende aspect van de taal.

In de praktijk is het proces echter tedious. Je moet nauwgezet de init/deinit-conventie volgen, en die discipline wordt wankel zodra je call stack langer wordt. Het is een duidelijke verbetering ten opzichte van een stille segfault of corrupt geheugen in C, maar vanuit Rust gezien ben jij nog steeds degene die de regel handmatig afdwingt: iets alloceren, het foutpad afhandelen en beslissen wie verantwoordelijk is voor de deinit, elke keer opnieuw.

Gelukkig komt Zig's TestAllocator hier te hulp. Het vangt niet alles automatisch op — je moet nog steeds testcases schrijven die de foutpaden uitoefenen — maar zodra je dat doet, is het vrij betrouwbaar. En dat is de valkuil: op papier lijkt dit allemaal logisch, totdat de code complexer wordt en deze bugs zich verstoppen.

Hier zijn de gevallen die het hardst aankwamen, elk vergeleken met hoe Rust hetzelfde afhandelt:

1. Memory leak: vergeten deinit

var iter = q.JsonPathIter.init(&root, std.testing.allocator);
try iter.append(&root, "$['a']");
// BUG: geen iter.deinit()
  • Gevangen door: MemoryLeakDetected, wijzend naar de dupe call binnen append.
  • Oplossing: defer iter.deinit(); direct na init.
  • Rust: Drop wordt automatisch uitgevoerd aan het einde van de scope, dus deze specifieke bug bestaat simpelweg niet. Hoewel leaks technisch mogelijk zijn (zoals Rc referentiecycli), moet je er in Rust echt moeite voor doen om dit te veroorzaken.

2. Memory leak: deinit overgeslagen op 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']"); // faalt -> iter lekt
    return iter;
}
  • Gevangen door: FailingAllocator{ .fail_index = 1 }, die de tweede append forceert naar MemoryLeakDetected.
  • Oplossing: errdefer iter.deinit(); direct na init.
  • Rust: Volledig geëlimineerd. Drop::drop wordt onvoorwaardelijk uitgevoerd bij elke scope exit, inclusief vroege returns via ?.

3. Memory corruption: deinit twee keer aangeroepen

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 gaat naar 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 bevat nu een (shallow) kopie van pointers van result
    defer result.deinit();      // BUG: geeft hetzelfde heap-data vrij waar cache.items nog naar wijst
    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();  // geeft DEZELFDE memory vrij die Layer 2 al vrijgaf
        cache.deinit();
    }
    for (queries) |qs| try cacheAndLog(json, qs, a, &cache);
}
  • Gevangen door: Uitvoering onder std.testing.allocator, die faalt bij de tweede query tijdens de cleanup van processAll. DoubleFree bevestigt dat dit een cross-function ownership bug is.
  • Oplossing: Slechts één laag mag de waarde bezitten. Aangezien de cache langer leeft dan cacheAndLog, behoort de ownership tot laag drie; laag twee mag geen defer deinit gebruiken na de overdracht.
  • Rust: Dit exacte scenario kan niet compileren. cache.push(result) verplaatst (moves) result — na die regel bestaat result niet meer als een bruikbare binding, dus er is geen manier om per ongeluk later drop(result) aan te roepen.

4. Memory corruption: wees-allocatie wanneer verplaatsing naar struct faalt

pub fn appendBuggy(self: *Iter, v: *Value, path: []const u8) !void {
    const duped = try self.allocator.dupe(u8, path);
    // BUG: geen errdefer
    try self.cursors.append(self.allocator, .{ .json = v, .path = duped });
}
  • Gevangen door: FailingAllocator{ .fail_index = 1 } die de groei van de array (de tweede allocatie) laat falen, waardoor duped (de eerste allocatie) een wees wordt.
  • Oplossing:

``zig const duped = try self.allocator.dupe(u8, path); errdefer self.allocator.free(duped); // vuurt alleen als append hieronder faalt try self.cursors.append(self.allocator, .{ .json = v, .path = duped }); ``

  • Rust: Veilig door constructie. Vec::push(item) verplaatst het item en slaagt of aborts bij OOM. Er is geen fallible push in de standaard API die een "gealloceerde maar niet gekoppelde" waarde teruggeeft die je per ongeluk kunt verliezen.

Bibliotheken en de core API

Het ecosysteem is nog erg jong. Er is een echt tekort aan bibliotheken, en zelfs iets basis als regex is nog niet volledig volwassen. Zo ondersteunt mvzr (de regex-engine beschikbaar in Zig) geen Unicode property escapes (\p{...}), wat direct een gat bleek te zijn bij het implementeren van de filterfuncties van RFC 9535. Daarnaast verandert de standaardbibliotheek van de taal zijn API van versie naar versie. Niets hiervan was verrassend, maar het is wel vermeldenswaardig.

Algemene indruk

De taal is anders dan Rust, maar het heeft een oprecht goede indruk achtergelaten. Het is rechttoe rechtaan, modern en razendsnel. Ik geloof dat het echt potentieel heeft om de ware opvolger van C te worden. Aan de andere kant is het nog jong, en dat is zichtbaar: de vorm van de taal zelf voelt op sommige plekken onafgemaakt, en ik vermoed dat het meer van de handige quality-of-life features en syntax sugar zal oppikken naarmate het rijpt.

Voor wat mij betreft wil ik graag blijven bijdragen aan het ecosysteem, en dat zal ik doen wanneer ik een project tegenkom dat het waard is om te bouwen.

Links