You Gotta Zag On Em

The Good

The ! error type is really nifty

So in Rust, if you want to to indicate a function can fail, it returns a result type:

fn open(path: &str) -> Result<File, std::io::Error>

If you're going to frequently use a single type, it's common to make your own "result" type that includes this:

 pub type Result<T> = std::result::Result<T, std::io::Error>;

fn open(path: &str) -> Result<File>

and this works well enough. There's even a handy syntax for unpacking result values:

fn get_data() -> Result<String, std::io::Error> {
    let f = open("file")?;
    f.read_all()
}

This little ? will get desugared (expanded) to something like:

fn get_data() -> Result<String, std::io::Error> {
    let f = match open("file") {
          Ok(v) => v,
          Err(e) => return e.into()
        }
    f.read_all()
}

The e.into() is important because it means the error returned by open doesn't have to use the exact error std::io::Error, but anything that can convert to it.

Rust's use of .into() and its reciprocal, .from() is extremely powerful. But without a library, this can get very verbose.

For example, say you have a function that could return two kinds of error. The typical way to encapsulate this is an enum, which in Rust behaves a lot like a union would in C:

pub enum Error {
    ConnectionError(somelib::ConnectionError),
    ParseError(someotherlib::ParseError),
  }

but you don't get those From implementations for free. You'd need to write them yourself:

impl From<somelib::ConnectionError> for Error {
    fn from(value: somelib::ConnectionError) -> Self {
        Error::ConnectionError(value)
    }
}


impl From<sometotherlib::ParseError> for Error {
    fn from(value: someotherlib::ParseError) -> Self {
        Error::ParseError(value)
    }
}

but this gets tedious quickly. The rust solution is a library like thiserror, which lets you write something like this:

use thiserror::Error;

#[derive(Error)]
pub enum Error {
    ConnectionError(#[from] somelib::ConnectionError),
    ParseError(#[from] someotherlib::ParseError),
}

Which is quite useful, but does require pulling in an extra library. The result is very "ergonomic" as Rustaceans likes to say, but takes a lot of infrastructure to get there. That's probably Rust in a nutshell.

In contrast, Zig has builtin support for error union types:

const Errors = error{ UnknownInstruction, NoDot };

You would have a function that looked used a ! syntax:

fn get_data(file: []const u8) Errors!SomeType

And you can either explicitly return UnknownInstruction like in rust, or use the try keyword:

try openFile("somepath");

In fact, you can actually omit the explict error union type, and Zig will make one for you:

// you wouldn't actually write Zig like this, because it doesn't like heap allocations. But that's for later.
fn get_data(file: []const u8) !SomeType {
    try open_file(file);
    return try parse_file();
}

That's pretty handy! What would've taken one or two dozen lines of rust is just baked into the language.

The comptime abstraction makes a lot of sense

Zig's marquee feature is the comptime syntax, which denotes that a variable or expression runs at compile time instead of runtime.

So for example, while rust needs a whole extra syntax for generics:

pub struct Stack<T> {
    stack: []T
}

impl<T> Stack<T> {
    fn pop(&mut self) -> Option<T> {
        // ...
    }
}

In Zig, you just write a normal function:

pub fn Stack(comptime T: type) type {
    return struct {
        stack: []T,

        const This = @This();

        pub fn pop(self: *This) ?T {
            // ...
        }
    }
}

You can kind of do this kind of thing with procedural macros, but it requires a lot of infrastructure. You need to build a special crate, do a bunch of imports, etc.

In Zig you can actually dedicate entire blocks to comptime:

comptime {
    const typ = @typeInfo(someval);
    for typ.Struct.fields |field| {
        // .. do something to every field
    }
}

I never really noticed how much extra syntax Rust had built up for handing this kind of thing.

You can do complex type expressions in rust like this:

fn use_type<T>(input: T) where T: IsNumric {
    // use numerics somehow
}

But in zig, you could write this like:

pub fn constant(comptime T: type, val: T) Instruction {
  if !is_numeric(T) {
      @compileError("not a numeric type!")
    }
}

It uses the exact same syntax as the rest of the language, so you don't have to remember some obscure syntax or where the <T> goes.

The @"literal" syntax is cute

This is a little thing. But say you're building a struct with a member whose name is a reserved word like type.

In rust, you'd simply give it a slightly different name:

pub struct MyStruct {
    type_: String
}

But zig has a special syntax for this that reminds me of the :"atom" syntax from Erlang or Ruby:

pub const MyStruct = struct {
    @"type": []const u8
};

I don't know if that's better per say, but it's definitely neat.

inline else is neat too

Imagine we have an enum with two types of number.

In Rust, it looks like this:

pub enum Number {
    I32(i32),
    I64(i64),
}

And in Zig:

pub const Number = union(enum) {
    I32: i32,
    I64: i64,
}

Now let's say we want to get that number as a string

impl Number {
  fn get_string(&self) -> String {
     match self {
         Number::I32(i) => i.to_string(),
         Number::I64(i) => i.to_string(),
     }
  }
}

Now in Zig, you could write this the same way:

fn to_string(n: Number, buf: []u8) ![]u8 {
   return switch(n) {
       .I32 => |i| try std.fmt.bufPrint(buf, "{d}", .{i}),
       .I64 => |i| try std.fmt.bufPrint(buf, "{d}", .{i}),
   };
}

but, you can also write it like this:

fn to_string(n: Number, buf: []u8) ![]u8 {
   return switch(n) {
       inline else => |i| try std.fmt.bufPrint(buf, "{d}", .{i}),
   };
}

i will have a different type for the .I32 and .I64 variants, but the inline else will generate the appropriate branches. Pretty cool!

defer and errdefer

  • "the biggest thing missing from C" - my wife*

I didn't use these much, and they're also found in one of my least favourite languages, Go.

But for a language intending to replace C, it's a big deal.

The idea goes, say you have some cleanup step that always should be done.

void access_shared(struct shared *data) {
  mutex_lock(&data->mutex);
  do_something(&data->shared);

  // a bunch of other junk

  mutex_unlock(&data->mutex);
}

so far so good. But Now let's imagine do_something is fallible.

int access_shared(struct shared *data) {
  int retcode;
  mutex_lock(&data->mutex);
  retcode = do_something(&data->shared);
  if (retcode < 0) {
    return retcode;
  }

  // a bunch of other junk

  mutex_unlock(&data->mutex);

  return 0;
}

But oh no! We've introduced an error – if do_something fails, we won't unlock the mutex.

This kind of error is extremely common. Rust's solution looks invisible:

fn access_shared(&mut self) -> Result<()> {
    let mut data = self.lock.unwrap();
    do_something(&mut data)?;
    Ok(())
}

What's happening behind the scenes here is that when data goes out of scope, it's automatically unlocked.

This is usually what you want! But it requires - say it with me - a lot of infrastructure. In this case, it implements the Drop trait. But this kind of magic can be unsettling.

The Go and Zig implementation, on the other hand, makes it immediately obvious what's happening.

fn access_shared(self: *Shared) !void {
    self.m.lock();
    defer self.m.unlock();

    try do_something(&self.data);
}

The errdefer case is a neat addition. It means it's easy to say, clean up after an error:

fn important_task(self: *State, allocator: Allocator) !*MyStruct {
    var foo = try doAnAllocation(allocator);
    errdefer deallocate(foo);

    try do_something();
}

neat!

The `std` available freestanding

This one takes some explaining. Rust's standard library is actually divided up into core and std. What's the difference? std assumes you have an allocator.

Allocators are a whole can of their own worms, but in short applications can put data on either the stack, where functions and their variables live, or the heap, which lives for an arbitrary amount of time.

Why the split? Well allocators are usually provided by the operating system, and Rust supports embedded applications where there might not be one.

But of course there's a lot of really handy stuff in std. Things like Vec and println! that most libraries want to use. So in practice, most libraries that support no_std, as it's called, have a feature std that's enabled by default, and a bunch of conditional compilation flags to disable features.

It works well enough, but it's definitely tedious.

Zig takes a different approach. Every function that wants to put data on the stack takes an Allocator. There's a built-in GeneralPurposeAllocator that you can probably use, or a bunch of more specific ones.

What this means is that any freestanding target (one without a hosting operating system) can still use the exact same standard library. No conditional compilation necessary. And no seperate crates or hardware-specific implementations.

The C interop is probably pretty cool

This is Zig's other headline feature, and I didn't use it at all.

But here's the gist:

In Rust, if you want to access a C library, you usually need bindings. If you're lucky, there's a -sys crate already generated. If not, you're probably going to be learning about bindgen.

Rust, of course, wants you to use C libraries as infrequently as possible because they can't uphold Rust's memory safety models. So there's a specific module that's very carefully wrapped and tested to provide a safe-ish interface.

Zig takes a different approach:

const c = @cImport({
    @cInclude("stdio.h");
});
pub fn main() void {
    _ = c.printf("hello\n");
}

You basically include a C library like any Zig library, and use it appropriately. There's obviously plenty of edge cases, but it's elegant in its simplicity.

It reminds me a lot of cython, a similar toolkit for Python.

Debug/ReleaseSafe/ReleaseFast/ReleaseSmall

Rust has two kinds of build:

  • debug builds, which is used for quick iteration and debugging
  • release builds, which take longer but are more optimized.

If you need anything beyond that, it's time to wade through Cargo options.

Zig, instead, lets you specify what you want to optimize for, and guides the compiler in the appropriate direction.

I've never found myself really needing to squeeze performance out of Rust code, but I love how obvious Zig lets you make your intentions.

The Bad

The documentation really isn't done

Zig is a much smaller language than Rust, and I certainly don't expect the same level of polish.

But you very quickly run into documentation limits. How do you split your code into multiple Zig files? TODO. What's the addrspace keyword do? who knows. How's JSON parsing handled? No documentation provided.

There's a lot of searching for "<issue> zig" or just looking at other people's code. I think I have been spoiled by Rust's and Python's impeccable documentation.

Quirks of comptime type system

anytype

Go has a type system, in theory, but in practice it was often insufficient to express what you want.

Before generics were added, a lot of functions end up looking like this:

// k8s.io/apimachinery@v0.28.3/pkg/api/meta
func Accessor(obj interface{}) (metav1.Object, error)

where interface{} basically means "whatever."

If it isn't acceptable, Go just panics during execution.

Zig's comptime features mean you're not going to get a runtime surprise. But it still means function signatures can be a little obscure.

Compare A Zig gzip function signature

fn decompress(allocator: mem.Allocator, reader: anytype) !Decompress(@TypeOf(reader))

To a rust one:

impl<R: BufRead> GzDecoder<R> {
    pub fn new(r: R) -> GzDecoder<R>
}

Once you can read the Rust interface syntax, you immediately know what kind of types will be acceptable.

With Zig, you can make some educated guesses, but you'll probably need to read some source code.

@TypeOf(i)

fn compare(this: anytype, that: @TypeOf(this)) bool {}
fn compare<T>(this: T, that: T) -> bool {}

They seem pretty similar, but there's some weird consequences imagine a function that returns an i32

fn get_val() i32 { return 1234 }

Now, this will work:

compare(get_val(), 1234);

But this will not:

compare(1234, get_val());

Because `1234` literal has the type comptime_int, which can be cooerced to any int But it doesn't know which one.

The rust code, though, will work just fine, because it only cares that the types are the same

so many reserved words

In some ways it's better than having obscure ascii syntax like rust does, but it feels messy. Sometimes I think the @"literal" syntax is just because there's so many variables you couldn't have otherwise.

No automatic test discovery

test "stack" {
  testing.refAllDecls(@This());
}

you need to put this in the root of your package. Why?

test is a language level construct and you can't discover recursively by default?

At least there's assert functions, unlike Go's boilerplate:

if expected != actual {
  t.Fatalf("expected value to be %v, got %v", expected, actual)
}

I'm pretty sure I coud type that snippet in my sleep now. Ugh.

The struct declaration feels mess

y In Zig, if you want to have a "method" associated with a struct, you simply put that function right in the struct.

const MyStruct = struct {
    value: i32,

    pub fn print(self: *MyStruct) void {}
}

Contrast that with Go:

type MyStruct struct {
  value: int,
}

func (s *MyStruct) Print() {}

or Rust:

struct MyStruct {
    value: i32,
}

impl MyStruct {
    fn print(&self) {}
}

Having the methods be mixed in with the variables means they're hard to pick out. Especially as functions get longer, the fields tend to get kind of lost in the sea of pub fn.

The try syntax doesn't chain well

try fallible().use_value()

doesn't work! but

(try fallible()).use_value()

does.

This is basically the reason Rust chose a .await suffix instead of an await prefix. It was very controversial at the time, but ultimately I think it was the right call. The ? syntax in rust lets you write code like this:

async fn get_weather_weer(&self) -> Result<Forecast> {
      Ok(self.client
          .get(FORECAST_URL)
          .query(&self.get_params())
          .send()
          .await?
          .json()
          .await?)
  }

Whereas in Zig you'd likely need a bunch of parentheses or intermediate variables

There's nothing wrong with using []const u8 for strings

but I don't like it. Zig might support unicode, but having an array of u8 values encourages ascii-like thinking. what's wrong with a string type, maybe with some useful iteration methods?

The Ugly

No cute mascot :C

cmon. I don't like Go but the gopher is very cute. Zig just has a boring stylised Z.

Maybe I'll try Hare next