Zig NEWS

loading...
Cover image for Faster Interface Style

Faster Interface Style

David Vanderson
・3 min read

Zig is moving away from the embedded struct style of interfaces towards a separate interface-on-demand style.

The old style caused some bad performance because when an implementation function changed state, the compiler assumed that the embedded interface struct might have changed, so it couldn't optimize as well.

Let's redo this old example in the new style. We'll have an interface that picks a number, and implement it two different ways.

The usage code:

const std = @import("std");

pub fn foo(iface: Interface) void {
    var i: usize = 1;
    while (i < 4) : (i += 1) {
        const p = iface.pick();
        std.debug.print("foo {d}: {d}\n", .{ i, p }); 
    }   
}
Enter fullscreen mode Exit fullscreen mode

Now we can copy interface structs so foo(iface: *Interface) becomes foo(iface: Interface).

Define the new-style interface named Interface that has a pick method:

const Interface = struct {
    // pointer to the implementing struct (we don't know the type)
    impl: *c_void,

    // can call directly: iface.pickFn(iface.impl)
    pickFn: fn (*c_void) i32,

    // allows calling: iface.pick()
    pub fn pick(iface: *const Interface) i32 {
        return iface.pickFn(iface.impl);
    }   
};
Enter fullscreen mode Exit fullscreen mode

Now we include a pointer to the implementing struct, and pass that to pickFn instead of a pointer to the interface.

Any type can implement this interface, so we have to use *c_void so impl can point to anything.

Our implementation that picks randomly:

const PickRandom = struct {
    // specific to PickRandom
    r: std.rand.DefaultPrng,

    fn init() PickRandom {
        return .{
            .r = std.rand.DefaultPrng.init(0),
        };
    }

    // implement the interface
    fn interface(self: *PickRandom) Interface {
        return .{
            .impl = @ptrCast(*c_void, self),
            .pickFn = myPick,
        };
    }

    fn myPick(self_void: *c_void) i32 {
        // cast typeless impl pointer to ourselves
        var self = @ptrCast(*PickRandom, @alignCast(@alignOf(PickRandom), self_void));

        // old random interface
        return self.r.random.intRangeAtMost(i32, 10, 20);

        // new random interface
        //return self.r.random().intRangeAtMost(i32, 10, 20);
    }   
};
Enter fullscreen mode Exit fullscreen mode

We no longer embed an Interface struct.

Now we call interface() to return an Interface struct that points back to this instance of PickRandom. We use @ptrCast to remove (erase) the type.

The Interface struct returned has a (type erased) struct pointer to this instance of PickRandom and a function pointer pickFn pointing to our function myPick. The interface will pass the struct pointer into myPick.

In myPick we know that self_void is a pointer to our struct, but we have to reconstruct the type. In addition to the @ptrCast we need to reconstruct the pointer alignment as well.

Without @alignCast zig complains that *c_void has alignment 1 while *PickRandom has alignment 8. That's on my x64 machine.

Here's the implementation that picks sequentially:

const PickSeq = struct {
    x: i32,

    fn init() PickSeq {
        return .{
            .x = 100,
        };  
    }

    fn interface(self: *PickSeq) Interface {
        return .{
            .impl = @ptrCast(*c_void, self),
            .pickFn = myPick,
        };
    }

    fn myPick(self_void: *c_void) i32 {
        // cast typeless impl pointer to ourselves
        var self = @ptrCast(*PickSeq, @alignCast(@alignOf(PickSeq), self_void));
        self.x += 1;
        return self.x;
    }
};
Enter fullscreen mode Exit fullscreen mode

Now we'll use both implementations:

pub fn main() !void {
    var pick_random = PickRandom.init();
    foo(pick_random.interface());
    std.debug.print("main: {d}\n", .{pick_random.interface().pick()});

    var pick_seq = PickSeq.init();
    const pick_seq_iface = pick_seq.interface();
    foo(pick_seq_iface);
    std.debug.print("main: {d}\n", .{pick_seq_iface.pick()});
}
Enter fullscreen mode Exit fullscreen mode

We can either get and call the interface together pick_random.interface().pick() or keep a copy of the interface for reuse. You can also have multiple copies of the interface pointing to the same implementation struct.

New vs. Old

In the old style you could easily trip up by copying the embedded interface struct. In the new style that's no longer a problem. The interface struct can be copied and passed to functions without worry.

Is this how all zig interfaces are moving? As I write this the Random interface has been changed and merged. But a similar change to the Allocator interface is raising other issues.

Discussion (10)

Collapse
jmc profile image
Daniele

Two things:

1) There's some stray foo(pick_random.interface()); and foo(pick_seq_iface); in the main() example which refer to a function that's not defined.

2) This "stinks" a bit because of the widespread use of *c_void, which is documented to be used only for C ABI compatibility. One way you can avoid this is to define a const AnyInterface = opaque {}; and then replace the various *c_void with *AnyInterface. In theory this could also be just anyopaque in the future.

Collapse
leecannon profile image
Lee Cannon

*c_void is going to be renamed anyopaque
github.com/ziglang/zig/issues/323

Collapse
david_vanderson profile image
David Vanderson Author

foo() is defined at the very top of the post.

I'm not sure how to feel about using *c_void. Does using opaque change the code/cast in a good way?

Collapse
jmc profile image
Daniele

Oh, my bad! I don't know how I missed the definition of foo().

As for *c_void vs opaque {}, AFAIK they're functionally identical, but opaque {} allows you to define a strong type alias whereas *c_void will accept just about any pointer. In retrospect, it probably doesn't matter very much in this specific case, but it seems good form anyway.

Collapse
kristoff profile image
Loris Cro

Reading some examples in the standard library it seems that the myPick function can also accept a correctly typed pointer and that c_void will be compatible even without cast.

Example:
github.com/ziglang/zig/blob/master...

Collapse
david_vanderson profile image
David Vanderson Author

Good point. It looks like you can do the pointer cast in the interface:
github.com/ziglang/zig/blob/master...

I'll have to look at that and maybe write a followup about it. Thanks!

Collapse
stacktracer profile image
Mike Hogye

Does anyone else find the new style easier to reason about?

For me the new style is a much better fit for my mental model of interfaces and impls. Not entirely sure why. Somehow the @fieldParentPtr pattern never felt intuitive, even after I'd worked with it a little bit.

Collapse
david_vanderson profile image
David Vanderson Author

I agree. It feels more like a straight-up function pointer. Also it's easier for me to understand how you can separate the interface struct, copy and store it around in different data structures. As long as the impl struct stays put!

Collapse
batiati profile image
Rafael Batiati

Great article, it's a good time to revisit previous experiments like github.com/alexnask/interface.zig

Collapse
kristoff profile image
Loris Cro

Great post, thank you!