My Zig experience: Migrating from C to Zig

TLDR is at the bottom :slight_smile:

Not satisfied with status quo, I’ve started developing a language (in C) some time ago, something between Java and Python, but lightweight. I love C and (as probably many of you) I also hate it, because there are so many annoying things that could very easily be done better (like header files, missing proper primitive types, unknown size strings, switch fallthrough, etc, etc).

But I from what I saw in Zig, it seemed like it was too innovative, complex with its comptime concept, the “big idea” C3 tries to avoid, also quite a few things to learn at first. But given how many footguns it removes and that there is a (mostly) working language server, some weeks ago, I decided to try it.

So here’s what I’ve experienced when I migrated my 40% done bytecode interpreter from C to Zig. The great things:

  • lots of convenience like
  • namespaces, no 30 character long prefixes
  • printing enum values instead of maintaining arrays of names along with every enum
  • switches on enums are checked for exhaustiveness
  • no more manual leak checking
  • more safety
  • error handling, error traces

But there also are some downsides I’ve come across:

  • vscode + zig plugin really is a downgrade when coming from clion, where nearly every error is catched before compilation
    • lots of missing errors, but also false errors (e.g. for loop with ranges)
    • zls had constant 100% cpu usage at some point (though single thread only)
  • sometimes all this comptime type stuff is confusing
    • e.g. initializing general purpose allocator
    • var gpa = std.heap.GeneralPurposeAllocator(.{}){}; const gpa_whatever = gpa.allocator();
  • sometimes compiler tries to do comptime where it should be at runtime and refuses to compile because it can’t prove that it will work
  • assigning to unions is annoying
  • printing is annoying
    • empty formatter arrays
    • buffered printing is very verbose and you need two objects for printing (+flushing)
  • -freference-trace does not always work (see below)
  • also had an compiler bug, which was very confusing

Non-issues I had to learn about:

  • zig is a compiler with an interpreter but the interpreter has no stack trace
    • there is the option -freference-trace=X for this!
    • btw Chris Lattner, the “fairly unknown” Zig (which has ~3x the GH stars of Mojo) had it first!
  • error unpacking is verbose, happy path is nested
    • only when using ‘if’, ‘catch’ keeps happy path at the same level

TLDR

Overall, while there have been quite some frustrations when I migrated my bytecode interpreter codebase, Zig has so much to offer which C doesn’t while keeping performance. Hour by hour, I feel more productive, code quality really has improved through better checks and better stdlib while lines of code have been reduced a lot (2700 → 1700 at feature parity)!

I hope the downsides will get reduced over time when Zig matures, but I’m optimistic about that. Anyhow, you got my support. Thanks for all your work!

11 Likes

Thanks for sharing your experience!

The good news is that most of the stuff on your downsides list is already acknowledged as problems with open issues and intent to solve them over time.

I am curious about a few of your points though:

Can you share an example of this?

1 Like

Reading my post again, it appears much more negative than it was supposed to be. Zig is amazing, just wanted to say that.

Unfortunately, at the moment I can’t, I was too lazy to use git. But I’ll add a big note to come back here, if I experience something like this again. If you don’t hear from me the mistake was probably on my end, but if it wasn’t then I’d suspect comptime_int type inference to be the cause.

Usually @as(T, some_comptime_int) where T is an explicit integer type (e.g. u32) will work to get around that.

Some of the confusing/annoying things are things you get used to, like the comptime_int stuff and the allocator initialization. I think it trips everyone up the first few times, but it actually makes sense why things work the way they do once the language becomes more familiar to you, at least it did for me. The std.heap.GeneralPurposeAllocator(.{}) is a comptime call and the curly braces that follow initialized the struct to default values. The reason you call gpa.allocator() is to get a uniform allocator interface so you could always switch allocator implementations with same interface.