What's the idiomatic design in zig when there is no interfaces/traits?

:wave: I’m only just starting my journey to learning zig today, and I was watching an interview from Mitchell Hashimoto about how coming from Go to Zig required a different programming mindset as there is not concept of interfaces/traits (like found in Go/Rust).

I would imagine then, that y’all define explicit types in your function signatures but he mentioned something about using ‘comptime’ instead to handle the interface use case.

I don’t really understand comptime right now so I was hoping y’all could help me with two things…

  1. Understand comptime
  2. Understand how it’s relevant to replacing interfaces/traits

Many thanks for any guidance you can give me.

2 Likes

The docs for comptime contains pretty much everything you need to know, read this first: Documentation - The Zig Programming Language.

comptime is relevant to traits / interfaces because it is the mechanism behind static dispatch. If you want to write generic code right now in Zig, you just write a function that accepts a type as one of its parameters, then refer to that type either in the type of other parameter(s) or as the return type:

// types are also just values that are compile-time known
fn add(comptime T: type, a: T, b: T) T {
    return a + b;
}

One important concept that makes comptime make more sense is that Zig’s compiler is lazy. It does not analyze functions, declarations, or anything that your program doesn’t use, so it doesn’t care, for example, that add(bool, true, false) would cause an error. Only if you actually invoked such a function call, would it then analyze the function given T=bool, and report an error.

This means that zig’s generics is basically compile-time duck typing.

We can enforce type constraints similar to traits by ourselves to give the user a more informative error message if their type is wrong. This is achieved by comptime assertions like so:

fn add(comptime T: type, a: T, b: T) T {
    comptime {
        // all code in this scope is interpreted at compile-time.

        // @typeInfo is the builtin function which provides type reflection.
        // This is a very simple example, we can do much deeper inspection if we wanted.
        switch (@typeInfo(T)) {
            .int, .float => {},
            // If T is a numerical type, the compiler won't even look at this branch,
            // so it won't cause a compile error. Again, the compiler is *lazy*.
            else => @compileError("Can only add ints or floats!"),
        }
    }

    return a + b;
}

I’ll also showcase anytype, since its sometimes used instead of passing a separate type parameter, but it can’t do anything the previous method can’t also do.

// anytype allows generics without an explicitly named T
fn add(a: anytype, b: @TypeOf(a)) @TypeOf(a) {
    return a + b;
}

There is also the separate problem of dynamic/runtime dispatch, which is usually what “interfaces” refers to, this is unrelated to comptime and is achieved via function pointers/vtables.

9 Likes

Total Zig noob here, but this blog post talks about interfaces. I don’t fully understand it yet, but seems like runtime interfaces are possible in Zig.

Are “interfaces” an officially supported design pattern in Zig? For example, it seems like std.mem.Allocator is an “interface”, right? Coming from Go, io.Reader and io.Writer are well understood and encouraged interfaces. Not sure if Zig has those as well.

Creating interfaces like the one in that article (dynamic dispatch) is possible as a by-product of having function pointers and type erasure (*anyopaque) which is nothing that, say, C doesn’t have. That is to say, it is not a distinct language feature, but the way of creating interfaces like std.mem.Allocator is a common convention.

In languages where interfaces are a language feature, like Go, they are really just using function pointers/vtables under the hood, Zig just makes you do it yourself. This also forces the programmer to be aware of the cost of dynamic interfaces on runtime performance.

The question of reader/writer interfaces is particularly tricky in Zig, because I can think of three main ways to pass a generic reader/writer:

  1. std.io.Any(Reader|Writer) is a function-pointer-based interface similar to std.mem.Allocator, and can be used when you don’t know what kind of reader/writer you want until runtime.

  2. std.io.Generic(Reader|Writer) creates a static reader/writer type for a specific purpose at compile-time. This is used by std.fs.File.(Reader|Writer) and std.ArrayList(u8).(Reader|Writer)) as examples.

  3. using anytype (explained above) as a writer type is a sort of inbetween which doesn’t have the runtime overhead of std.io.Any(Reader|Writer), and is also more flexible then std.io.GenericReader. This is good for library code, for example std.fmt.Format and std.json.stringify from the standard library.

3 Likes

Related topic.