Contents

Writing abstract interfaces in zig is an absolute nightmare!

Being a bit bored and having a some extra time during Christmas motivated me to learn zig. As usual, when learning a new language, you experiment a bit, write some small test programs to discover the syntax and idiomatic ways to solve problems and learn the standard library. One of the fundamental things that you’ll eventually find the need for, sooner or later, is defining abstractions and interfaces, and I was a bit shocked to discover that zig simply doesn’t support that!

What do I mean?

I’m talking about an equivalent of pure abstract classes in C++. Something like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class Calc {
public:
    virtual ~Calc() = default;

    virtual int sum(int a, int b) = 0;

    virtual int sub(int a, int b) = 0;

    // ...
};

class MyCalc : public Calc {
public:
    int sum(int a, int b) override {
        // implementation of `Calc::sum` interface
        return a + b;
    }
    // ...
};

Before I get to zig, let’s think about how we could implement this in C? There’s a very good paper describing the details of how to approach object oriented programming in C. The gist is to have an indirection:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct Calc {
    int (*sum)(int a, int b);

    int (*sub)(int a, int b);
};


// MyCalc module implementing the interface

int myCalc_sum(int a, int b) {
    return a + b;
}

int myCalc_sub(int a, int b) {
    return a - b;
}

struct Calc myCalc_create() {
    struct Calc c = {
        .sum = myCalc_sum,
        .sub = myCalc_sub,
    };
    return c;
}

This is the basics of dynamic dispatch. Of course, I’ve ommitted a lot of important details i.e. having the interface definition opaque, passing pointer to the object to methods etc - it doesn’t really matter here. Additionally, the downside of this simplified approach is that if you create multiple instances implementing Calc then the function pointers will be duplicated multiple times across them so, we usually go a step further and define a vtable:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
struct CalcVtable {
    int (*sum)(int a, int b);

    int (*sub)(int a, int b);
};

struct Calc {
    const struct CalcVtable* vtable;
};

// wrappers for interface functions
int calc_sum(const struct Calc* c, int a, int b) {
    return c->vtable->sum(a, b);
}

// MyCalc module implementing the interface

int myCalc_sum(int a, int b) {
    return a + b;
}

int myCalc_sub(int a, int b) {
    return a - b;
}

static const struct CalcVtable myCalcVtable = {
    .sum = myCalc_sum,
    .sub = myCalc_sub,
    // ...
};

struct Calc myCalc_create() {
    struct Calc c = {
        .vtable = &myCalcVtable,
    };
    return c;
}

In general, this is all that C++ is hiding from you and doing behind the scenes to make our lives simpler and focus on the code rather than the technicalities.

The client code can use Calc as an abstract interface (and its associated functions e.g. calc_sum) without any knowledge about how and where it is implemented.

Now, C being C, requires that you implement all of that manually since there are no language constructs to support that. Some projects do that. One prominent example I can think of from the top of my head would be DirectFB. DirectFB defines interfaces in that way for majority of its primitives here’s one example.

How does that relate to zig?

I only spent a couple of weeks with the language but so far I don’t see any language support for abstract interfaces. In fact, looking at how standard library implements allocators, I’m convinced there’s none! Let’s have a look together shall we?

As an example, let’s look at concat.

This is the function signature:

1
pub fn concat(allocator: Allocator, comptime T: type, slices: []const []const T) Allocator.Error![]T

It takes an Allocator so, what is an Allocator?

It’s just a struct with two pointers:

1
2
3
ptr: *anyopaque

vtable: *const VTable

VTable in this case defines a set of function pointers that Allocator implementations have to populate. That sounds familiar doesn’t it?

Let’s have a look on how allocators are implemented. I’ve picked GeneralPurposeAllocator completely arbitrarily. The interesting bit is here:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
pub fn allocator(self: *Self) Allocator {
    return .{
        .ptr = self,
        .vtable = &.{
            .alloc = alloc,
            .resize = resize,
            .free = free,
        },
    };
}

This populates the Allocator structure with its own vtable and a pointer to its own instance. Case closed.

Why does this suck?

Because it’s completely manual!!! Surely your language is lacking if it doesn’t support such fundamental programming concept like abstract interfaces. In fact, I’d be okay with lack of support for this if there was any other, equivalent idiomatic construct given in the language but there isn’t.

Now, you might say that it’s not idiomatic to do things like that in zig but zig does it itself in its own standard library!

Another, more serious, reason why this suck is that again you have to rely on explicit type casts in the code implementing the interfaces. In other words, there’s completely no type safety guarantees! Here’s an example of what I mean:

Let’s have a look on one of Allocator’s interface functions - alloc:

1
alloc: *const fn (ctx: *anyopaque, len: usize, ptr_align: u8, ret_addr: usize) ?[*]u8

Now, this is the implementation from GeneralPurposeAllocator:

1
2
3
4
5
6
7
fn alloc(ctx: *anyopaque, len: usize, log2_ptr_align: u8, ret_addr: usize) ?[*]u8 {
    const self: *Self = @ptrCast(@alignCast(ctx));
    self.mutex.lock();
    defer self.mutex.unlock();
    if (!self.isAllocationAllowed(len)) return null;
    return allocInner(self, len, @as(Allocator.Log2Align, @intCast(log2_ptr_align)), ret_addr) catch return null;
}

You see the problem? We’re operating on anyopaque and are forced to explicitly cast to our own type inside the implementation. This is no better than C! This is exactly the same as:

1
2
3
void foo(void *user_data) {
    Foo* foo = (Foo*)user_data;
}

False advertisement

Maybe it’s just me or maybe it’s the hype around the language which is being sold by big Youtubers, like The Primeagen, as the next big thing. The lightweight alternative to rust! The perfect new language that’s gonna take over everything by storm. The most enjoyable new language to write in. So on and so forth…

I mean, it builds expectations which inevitably will lead to disappointment.

The language is okay but it’s definitely not mature enough to be placed in the same category as e.g. Rust or any other well established systems programming language.

Since I mentioned Rust, Rust supports building abstractions through e.g. traits that are closer in nature to a classic inheritance model we all know from e.g. C++.

zig in its current form is probably closer in nature to nim although, ironically, nim does seem to support inheritance and dynamic dispatch.

Conclusion

Learning a new programming language always brings in a new valuable insight. It’s interesting to see how different languages and tooling around them solve similar problems. Despite initial let down I’m gonna continue learning zig and plan to use it in practical application as well.