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 });
}
}
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);
}
};
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);
}
};
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;
}
};
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()});
}
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.
Oldest comments (12)
Great post, thank you!
Reading some examples in the standard library it seems that the
myPick
function can also accept a correctly typed pointer and thatc_void
will be compatible even without cast.Example:
github.com/ziglang/zig/blob/master...
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!
Great article, it's a good time to revisit previous experiments like github.com/alexnask/interface.zig
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.
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!
Two things:
1) There's some stray
foo(pick_random.interface());
andfoo(pick_seq_iface);
in themain()
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 aconst AnyInterface = opaque {};
and then replace the various*c_void
with*AnyInterface
. In theory this could also be justanyopaque
in the future.foo() is defined at the very top of the post.
I'm not sure how to feel about using
*c_void
. Does usingopaque
change the code/cast in a good way?Oh, my bad! I don't know how I missed the definition of
foo()
.As for
*c_void
vsopaque {}
, AFAIK they're functionally identical, butopaque {}
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.*c_void
is going to be renamedanyopaque
github.com/ziglang/zig/issues/323
I know, but that just means it'll accept any "opaque" type, as I clarified in zig.news/jmc/comment/51.
This is relatively simple. Thanks。
I hope the official can support closures and interfaces.