💾 Archived View for yujiri.xyz › software › zig.gmi captured on 2023-12-28 at 15:28:10. Gemini links have been rewritten to link to archived content
⬅️ Previous capture (2023-09-08)
-=-=-=-=-=-=-
Overall, Zig is my favorite language right now. It has many brilliant, elegant ideas, like the ways you use comptime to replicate the benefits of powerful type systems without making the languge too complex, and that files are structs.
i also think that its ethos is a sort of antidote for the rot that's eaten most of the software world. I like that it doesn't depend on libc, is working on native codegen instead of hard-depending on LLVM, is conscientous about supporting non-mainstream platforms (there's even some Plan 9 stuff in its stdlib), is planned to have a formal specification and not have its own package repository. It also gets insanely good performance and small binaries.
Despite my emotional favoritism for it, i'm under no illusion that it's as practical as higher-level languages for writing application software. Manual memory management and not having closures is quite pain, also trait objects are hard because you have to make vtables manually which takes a lot of boilerplate, but I don't consider those things flaws as it's meant to be a low level language.
They advertize "Zig is designed such that the laziest thing a programmer can do is handle errors correctly", and they did a really good job with that, they achieved it in a way Rust didn't, mostly because thanks to error set inference, you don't have to fuck around with sum types to be able to return more than one error type from a function. The compiler infers and flattens the set of possible errors a function can return.
The error system has some limitations though. Errors are *just* the name of the error type, they don't support carrying a detail payload. For example, if you're writing a parser, you can't return `error.InvalidChar{.line = 7}`, you can only return `error.InvalidChar`.
Likewise, there's no way to *wrap* an error with context, as in the Go library github.com/pkg/errors. You get error return traces in debug builds, which are similar to stack traces, but no tools for wrapping an error for end user display.
https://godocs.io/github.com/pkg/errors
It has a good formatter which has no configuration, uses 4 space indentation (I'd prefer tabs but this is alright), and instead of trying to always take responsibility for line breaks like rustfmt, it uses whether there's a trailing comma to decide whether to break a list onto one line per element, which is wonderful!
Cross-compilation is easy, you can just passs `-target $target` to the build command.
Unused variables are compile errors. I complained about this when I wrote my earlier review of Go, but Zig is even worse, because unused *function parameters* are also errors. In Go it's only non-parameter local variables. This is a big hassle when you're just trying to test something.
Debug-printing things is a bit of a pain: `std.debug.print("message\n", .{});`. The newline is not added automatically (there isn't a variant function that adds it), and you have to write the argument tuple `.{}` even if it's empty.
Imports are more verbose than most languages, because you can't import multiple symbols in one line. This is the standard stuff that goes at the top of every file:
const std = @import("std"); const heap = std.heap; const ArrayList = std.ArrayList; // etc.
In fairness, this makes it easier to search for where something is defined.
There is no operator overloading. For types that aren't primitive but that have reasonable interpretations for arithmetic operators, like 2D coordinates or durations, you have to write things like `a.add(b).subtract(c)` instead of `a + b - c`. You can't even use `==` on non-primitive types. You have to use `std.mem.eql` for slices, and `std.meta.eql` for structs and unions.
There are no closures, so despite that there are generics, you can't implement many useful collection methods like "find item by closure". Generics seem to be mostly useful for defining data structures.
You can't destructure nullability and a sum type at the same time; you have to use two levels:
const T = union(enum) { a: u8, b: i8, }; fn f(nullable_t: ?T) void { if (nullable_t) |t| { switch (t) { .a => {}, .b => {}, } } }
In Rust you could write:
fn f(t: Option<T>) { match t { Some(T::A) => {}, Some(T::B) => {}, } }
On the other hand, there are some areas where Zig is more ergonomic than other languages, like the ability to omit type names when Zig knows what type to expect. For example, note how in the switch in that last example I didn't have to write `T.a` and `T.b`. Likewise, if I'm returning a `T` from a function in Zig I can write `return .{.a = 4}`, instead of `return T{.a = 4}`. This is *really* nice for functions that take enum arguments. You can just do `func(arg, .variant)`.
Another ergonomic win is default values for struct fields. You can just put a default value when defining the struct, instead of having to write a constructor or implement a Default trait.
Now the big one: the `orelse` and `catch` keywords for unwrapping optionals and error unions are sometimes outstandingly more flexible and concise than the equivalents in other languages. Compare them to Rust's `?`, which translates to `orelse return null` for optionals or `try` for error unions. `?` is, at first glance, shorter than either. But it only works when the function returns the right type. For example, if your function returns nothing, it can't use `?`; you have to write this:
match opt { Some(v) => v, None => return, }
Whereas in Zig you can just write `orelse return`. This specific case has come up surprisingly often for me in both languages.
To unwrap an optional with a default value, Rust gets longer: `?` becomes `.unwrap_or(default)` or `.unwrap_or_else(|| default())` if the default value is a function. While Zig gets shorter: `orelse return null` becomes `orelse default()`.
You can even use Zig's `orelse` and `catch` to short-circuit to intermediate scopes like loops and labeled blocks: I use `orelse continue` very often! In Rust you'd have to resort to the verbose `match` block in those cases, because `?` can only short-circuit to the function scope.
I think what Zig's designers realized that Rust's didn't is that syntax is more flexible than methods because it doesn't force a new scope. You can easily make a new scope with Zig's tools if you want, but you don't have to.
And shall we compare to exception languages? Consider this Python code:
# errors try: val1 = func1() except: val1 = default # nulls val2 = func2() if val2 is None: return func3(val2)
Versus:
# errors val1 = func1() catch default # nulls func3(func2() orelse return)
Zig blows it out of the water, which is really surprising comparing a low-level language to a dynamic one.
Of course, you could show plenty of examples where Zig would be similarly or more verbose than Python, but I'm just so impressed and intruiged that it competes with higher-level languages in these ways.
I don't like that accessing a union field looks the same as accessing a struct field. Don't have much to say about this, I just feel it's unclear.