Een Rust-veteraan schreef zijn lib opnieuw in Zig. Wat een .NET'er eruit haalt

Zig versus Rust ging viral op Hacker News. Wat het debat over comptime, allocators en tooling betekent voor je keuzes als .NET-developer.

Jean-Pierre Broeders

Freelance .NET Developer

21 september 202612 min. leestijd
Een Rust-veteraan schreef zijn lib opnieuw in Zig. Wat een .NET'er eruit haalt

Iemand met zeven jaar Rust op de teller schreef zijn JSONPath-library opnieuw in Zig, blogde erover, en de post stond binnen een dag bovenaan Hacker News met ruim driehonderd reacties. Zijn conclusie was niet zwart-wit. Zig was "straightforward, modern en snoeihard", maar de taal voelde op plekken ook onaf. Dat soort genuanceerde stukken doen het goed, en terecht.

Ik schrijf zelf al jaren C# en Azure-werk voor klanten. Zig noch Rust komt in mijn dagelijkse stack voor. Toch bleef ik hangen bij die post, want de dingen waar de auteur tegenaan liep zijn precies de assen waarlangs je een runtime kiest. Geheugen, compile-time codegeneratie, tooling. Op elk van die punten heeft .NET een eigen antwoord, en het is nuttig om die naast elkaar te leggen. Niet om te bepalen wie wint. Wel om scherper te krijgen wanneer je als .NET-developer eigenlijk buiten de CLR zou moeten stappen, en wanneer dat gewoon jeuk is.

Wat er op Hacker News speelde

De kern van het stuk: de auteur bouwde dezelfde library twee keer en vergeleek de ervaring. Zig was verrassend snel productief. De build.zig beviel hem beter dan verwacht, en de vlakke bestandsstructuur die Zig aanmoedigt liet hem twijfelen of al die geneste mappen in zijn Rust-project ooit ergens goed voor waren.

Maar er waren scherpe randen. De IDE-ondersteuning noemde hij bijna afwezig, alleen syntax highlighting en wat autocompletion. Het ecosysteem is jong, dus liep hij tegen een regex-implementatie aan zonder Unicode-ondersteuning en kon hij RFC 9535 niet volledig halen. En het geheugenbeheer legde alle discipline bij hemzelf neer. Vergeten deinit, lekken op de foutpaden, dubbele frees. Dingen die Rust met zijn Drop en ?-operator grotendeels voor je afvangt.

Dat is de spanning waar het hele stuk om draait. Zig geeft je controle en houdt de taal klein. Rust geeft je garanties en betaalt dat met complexiteit. En ergens aan de andere kant van dat spectrum staat .NET, dat allebei die problemen simpelweg wegautomatiseert met een garbage collector en een JIT. De vraag is wat je daarvoor inlevert.

comptime is geen macro, en zo voelt het ook niet

Het meest interessante aan Zig is comptime. Je draait gewone Zig-code tijdens het compileren, met dezelfde taal, zonder aparte macro-syntax. Types zijn waardes die je kunt doorgeven en manipuleren. Een generieke container is letterlijk een functie die een type teruggeeft.

fn Matrix(comptime rows: usize, comptime cols: usize, comptime T: type) type {
    return struct {
        data: [rows][cols]T,

        pub fn zero() @This() {
            return .{ .data = std.mem.zeroes([rows][cols]T) };
        }
    };
}

const M = Matrix(3, 3, f32);

Wie uit C++ komt en zich ooit door een template-foutmelding van veertig regels heeft geworsteld, snapt meteen waarom mensen hier enthousiast van worden. Geen aparte template-taal die je erbovenop moet leren. Gewoon code.

In .NET los je dit met twee losse gereedschappen op. Generics dekken het runtime-deel, en die zijn krachtiger dan mensen denken, want de CLR specialiseert waardetypes echt naar aparte machinecode in plaats van alles achter een pointer te verstoppen. Voor het compile-time deel heb je sinds een paar jaar source generators. Die draaien in de Roslyn-compiler en schrijven C# bij tijdens de build. Het bekendste voorbeeld zit al in de standaardbibliotheek:

public partial class LogParser
{
    [GeneratedRegex(@"^\d{4}-\d{2}-\d{2}")]
    private static partial Regex DatePrefix();
}

Die DatePrefix() bestaat niet in jouw bestand. De generator schrijft de volledige, uitgerolde regex-matcher als C# tijdens het compileren, zodat er op runtime niks meer geparsed of geïnterpreteerd hoeft te worden. Hetzelfde idee zit achter System.Text.Json-serializers en de logging-source-generator. Je betaalt de kosten één keer, in de build, en houdt een schone startup over.

Het verschil met Zig is filosofisch. In Zig is compile-time uitvoering onderdeel van de taal zelf, één mechanisme dat overal opduikt. In .NET is het een apart, wat zwaarder gereedschap dat je bewust inzet als een aparte assembly. Source generators schrijven is geen pretje. Je werkt met syntax trees, je debugt een compiler-plugin, en de foutmeldingen zijn niet altijd behulpzaam. Maar het resultaat is hetzelfde soort winst: werk verschuiven van runtime naar buildtime. Dat Zig dat toegankelijker maakt is een echte les. Niet genoeg om van stack te wisselen, wel genoeg om je af te vragen of je dat ene stuk reflectie in je hot path niet had kunnen weggenereren.

De allocator die overal langskomt

Hier wordt het contrast het grootst. In Zig krijgt bijna elke functie die geheugen nodig heeft een allocator mee. Expliciet, als parameter. Dat maakt zichtbaar wie er alloceert en dwingt je na te denken over opruimen.

fn tokenize(allocator: std.mem.Allocator, input: []const u8) ![]Token {
    var tokens = std.ArrayList(Token).init(allocator);
    errdefer tokens.deinit();

    var i: usize = 0;
    while (i < input.len) : (i += 1) {
        // ... vul tokens
    }
    return tokens.toOwnedSlice();
}

Let op die errdefer. Gaat er verderop iets mis en bubbelt de fout omhoog via !, dan ruimt Zig de lijst op. Vergeet je die regel, dan lek je geheugen op precies de paden die je het minst test. De auteur van de blogpost noemde dit als een van de grootste valkuilen, samen met dubbele frees over functiegrenzen heen. Zig geeft je een TestAllocator die dit soort lekken tijdens tests detecteert, maar dan moet je die faalpaden wel zelf in tests hebben zitten.

In C# bestaat dit hele probleem niet, omdat de GC het doet. En voor negentig procent van het werk dat ik voor klanten doe is dat precies goed. Een API die JSON in en uit stuurt heeft geen handmatig geheugenbeheer nodig, en elke poging daartoe is verspilde tijd.

Maar de interessante gevallen zitten in die andere tien procent. Als je een hot path hebt waar de GC-druk je throughput opeet, geeft .NET je tegenwoordig gereedschap dat verrassend dicht bij Zig komt. Span<T> laat je over een stuk geheugen werken zonder te kopiëren, en ArrayPool<T> laat je buffers hergebruiken in plaats van steeds nieuwe te alloceren.

static Token[] Tokenize(ReadOnlySpan<char> input)
{
    var buffer = ArrayPool<Token>.Shared.Rent(input.Length);
    try
    {
        var count = 0;
        for (var i = 0; i < input.Length; i++)
        {
            // ... schrijf naar buffer[count++]
        }
        return buffer.AsSpan(0, count).ToArray();
    }
    finally
    {
        ArrayPool<Token>.Shared.Return(buffer);
    }
}

Kijk naar dat try/finally. Dat is jouw errdefer. De Return moet gebeuren, ook als er halverwege een exception vliegt, anders lek je een buffer terug de pool in die nooit meer vrijkomt. Precies dezelfde discipline die Zig van je vraagt, alleen kies je er in .NET zelf voor op de plekken waar het telt, in plaats van overal. Dat vind ik de betere balans. De GC vangt het brede geval af, en op de paar plekken waar je echt om allocaties geeft pak je de touwtjes terug.

Tooling: het gemis dat je workflow verandert

De auteur schreef iets moois over de zwakke IDE-ondersteuning van Zig. Hij verwachtte het als een handicap te ervaren, maar het duwde hem juist naar een terminal-setup met Helix en Zellij, en achteraf beviel dat hem. Dat is een eerlijk observatie, maar ik zou er niet te veel achter zoeken. Voor een solo-project van vijf bestanden werkt dat. Voor een codebase van tweehonderdduizend regels met vijf developers is een language server die betrouwbaar refactort geen luxe.

Dit is waar .NET al twintig jaar sterk staat, en het is makkelijk om dat te vergeten omdat het er gewoon is. Roslyn is niet alleen een compiler, het is een analyse-API waar de hele tooling op leunt. Betrouwbare rename over je hele solution, analyzers die fouten vinden voor je code draait, code fixes die met één toets een heel patroon rechttrekken. De blogpost noemt dat Zig een regex-lib zonder Unicode had. In de .NET-wereld is de vraag zelden of iets bestaat, maar welke van de vijf opties de minste bagage heeft.

Interessant genoeg raakt de post ook language servers voor domeinspecifieke dingen. Datzelfde thema zag ik terug toen Protobuf eindelijk een fatsoenlijke language server kreeg. Tooling die je contract begrijpt, niet alleen je tekst, verandert echt hoe het voelt om ermee te werken. Zig mist dat nog grotendeels, en dat is geen detail als je er acht uur per dag in zit.

Wanneer stap je echt uit de CLR?

Dit is de vraag die er voor mijn praktijk toe doet. Zig en Rust zijn verleidelijk, en er is een soort statusgevoel om "dichter op het metaal" te werken. Maar de meeste redenen die ik mensen hoor noemen zijn geen echte redenen.

Startuptijd is er wel een. Als je een CLI-tool levert of een serverless-functie hebt die cold starts voelt, dan doet een native binary die in milliseconden opstart er toe. Sinds .NET dat met Native AOT ondersteunt, is een groot deel van dat argument verdwenen.

dotnet publish -c Release -r linux-x64 -p:PublishAot=true

Wat je terugkrijgt is één native binary, zonder JIT, zonder runtime die eerst moet opwarmen. Geen comptime zoals Zig, maar wel het resultaat dat mensen ermee willen: klein, snel opstartend, uitleverbaar als één bestand. De trimmer knipt weg wat je niet gebruikt, en je moet oppassen met reflectie, maar voor het soort tools waarvoor je anders naar Zig zou grijpen werkt het.

De echte redenen om wel over te stappen zijn smaller dan de hype suggereert. Je hebt keiharde controle over geheugenlayout nodig, bijvoorbeeld voor een embedded target of een game-engine. Je wilt geen GC-pauzes, letterlijk nul, in een real-time systeem. Of je moet linken tegen een C-ABI op een manier waar interop je in de weg zit. Dat zijn geldige redenen, en als je erin zit weet je het.

Voor de rest, de API's, de achtergrondservices, de integraties, de datapijplijnen die het gros van het werk vormen, is de CLR gewoon de productievere keuze. Niet omdat de andere talen slecht zijn. Omdat de kosten die Zig en Rust van je vragen, het handmatige geheugenbeheer of het gevecht met de borrow checker, alleen renderen als je er iets voor terugkrijgt dat je daadwerkelijk nodig hebt.

Wat ik meeneem

De blogpost eindigt genuanceerd, en dat ga ik ook doen. Zig laat zien dat compile-time codegeneratie toegankelijk kan zijn, en dat is een spiegel voor iedereen die reflectie in een hot path heeft staan die net zo goed een source generator had kunnen zijn. Het allocator-model laat zien hoe waardevol expliciet zijn is, en hoe fijn het is dat je in .NET die explicietheid kunt opzoeken zonder hem overal te moeten dragen.

En het herinnert me eraan dat tooling geen bijzaak is. Een taal die snoeihard is maar je geen betrouwbare rename geeft, kost je op schaal meer dan hij op papier bespaart. Dat is makkelijk vergeten als je verliefd bent op een benchmark.

Veelgestelde vragen over Zig, Rust en .NET

Moet ik als .NET-developer Zig of Rust leren?

Leren mag altijd, en een weekend met Zig scherpt je begrip van geheugen en compile-time codegeneratie op een manier die je C# beter maakt. Voor productiewerk zou ik pas overstappen als je een concreet probleem hebt dat de CLR niet oplost, zoals nul GC-pauzes of keiharde controle over geheugenlayout. Nieuwsgierigheid is een prima reden om te spelen, geen reden om je stack te wisselen.

Wat is het .NET-equivalent van Zig's comptime?

Er is geen één-op-één-equivalent. Generics dekken het runtime-deel van generieke code, en source generators dekken het genereren van code tijdens de build via de Roslyn-compiler. Samen benaderen ze wat comptime doet, maar het zijn twee aparte gereedschappen in plaats van één taalmechanisme, en source generators schrijven is duidelijk meer werk dan een comptime-functie.

Maakt Native AOT .NET net zo snel opstartend als een Zig-binary?

Voor het opstarten komt het dichtbij, want Native AOT levert een native binary zonder JIT die in milliseconden start, wat het cold-start-argument voor serverless en CLI-tools grotendeels wegneemt. Op piek-throughput kan de JIT met tiered compilation soms nog inlopen omdat hij op runtime kan optimaliseren. Voor cold starts is AOT de winst, voor langdraaiende services ligt het genuanceerder.

Wanneer is handmatig geheugenbeheer in C# nodig?

Bijna nooit in gewone API's of services, daar doet de GC het prima en is elke poging tot handmatig beheer verspilde tijd. Het wordt relevant in hot paths waar GC-druk je throughput opeet. Dan pak je Span, ArrayPool en stackalloc erbij om allocaties te vermijden, en dat kost dezelfde discipline rond opruimen die Zig van je vraagt, alleen op de plekken waar het telt.

Verder lezen


Conclusie: Behandel het Zig-versus-Rust-debat niet als een oproep om van stack te wisselen, maar als een checklist voor je eigen runtime. Comptime vraagt of je reflectie kunt weggenereren, het allocator-model vraagt waar je echt om geheugen geeft, en de tooling-klacht herinnert je eraan wat de CLR je al twintig jaar cadeau doet.

Bronnen: What Zig felt like, coming from Rust · Hacker News-discussie.

Wil je op de hoogte blijven?

Schrijf je in voor mijn nieuwsbrief of neem contact op voor freelance projecten.

Neem Contact Op