Zig has no built-in Interface but this doesn’t mean you can’t build your own runtime dispatched polymorphism. I find William Wong’s Zig Interface Revisited post so helpful on this matter. In his post, he manages to define his own vtable by manually wrapping implementation functions to match erased *anyopaque signature, and assigns each behavior to its own virtual function field.
It works and it is good to know that you don’t have to rely on comptime dispatch only. Aside from vtables requiring extra indirections, the biggest trade-off is that you have to manually define each vtable’s delegation functions and then assign each delegation function to corresponding interface function field. This could be ok for a few interfaces but repetition and scattered signatures across lines can be error-prone.
So I came up with a simple idea, reducing boilerplate code through comptime ducking. This is a contract that contains all the function fields to pass to the generic vtable function. This function returns a vtable for each contract that has erased implementation pointer with delegated methods. Of course, this still requires the programmer to manually define a contract with an interface that utilises the vtable accordingly. I find this approach reduces repetition and checks for any mismatches between the implementation function parameters and the contract definition. There is room to improve with this approach, but I’m going to present the version that satisfies me.
You may want to check the Wong’s post for manual one so you can compare them well.
fn VirtualMethodTable(comptime T: type) type {
return struct {
implPtr: *anyopaque,
methods: *const T,
pub fn init(ptr: anytype) @This() {
const ImplType = @TypeOf(ptr.*);
const Static = struct {
const methods: T = blk: {
const impl_name = @typeName(ImplType);
var temp: T = undefined;
for (std.meta.fields(T)) |field| {
const expected_fn = @typeInfo(@typeInfo(field.type).pointer.child).@"fn";
const actual_fn = @typeInfo(@TypeOf(@field(ImplType, field.name))).@"fn";
if (expected_fn.return_type.? != actual_fn.return_type.?) {
@compileError(
std.fmt.comptimePrint(
"Return type mismatch on method \"{s}\" for \"{s}\".",
.{
field.name,
impl_name,
},
),
);
}
const expected_params = expected_fn.params[1..];
const actual_params = actual_fn.params[1..];
if (expected_params.len != actual_params.len) {
@compileError(
std.fmt.comptimePrint(
"Param length mismatch on method \"{s}\" for \"{s}\".",
.{
field.name,
impl_name,
},
),
);
}
for (expected_params, actual_params) |e, a| {
if (e.type.? != a.type.?) {
@compileError(
std.fmt.comptimePrint(
"Param type mismatch on method \"{s}\" for \"{s}\".",
.{
field.name,
impl_name,
},
),
);
}
}
@field(temp, field.name) = @ptrCast(
&@field(ImplType, field.name),
);
}
break :blk temp;
};
};
return .{
.implPtr = @ptrCast(@alignCast(ptr)),
.methods = &Static.methods,
};
}
};
}
The initialisation of the VirtualMethodTable is likely to be improved. It assumes that the first parameter of each function is a self pointer for the implementation, so it skips the first parameter of each function. This is an implicit contract, but it could be enforced through comptime type checking, though one must ensure that all use cases are covered, of course.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
var logger_impl = Logger.init(init.io, std.Io.File.stdout());
var iface = Interface.init(&logger_impl);
iface.log("Hello, world!\n");
iface.log("This is from logger interface!\n");
iface.logFmt("{any}\n", .{iface});
}
const Logger = struct {
file: std.Io.File,
io: std.Io,
pub fn init(io: std.Io, file: std.Io.File) Logger {
return Logger{
.file = file,
.io = io,
};
}
pub fn log(self: *Logger, message: []const u8) void {
var writer = self.*.file.writer(self.*.io, &.{});
writer.interface.writeAll(message) catch {};
}
};
const Interface = struct {
const Vmt = VirtualMethodTable(
struct {
log: *const fn (impl: *anyopaque, message: []const u8) void,
},
);
vtable: Vmt,
pub fn init(impl: anytype) Interface {
return Interface{
.vtable = Vmt.init(impl),
};
}
pub fn log(self: *Interface, message: []const u8) void {
self.*.vtable.methods.*.log(self.*.vtable.implPtr, message);
}
pub fn logFmt(self: *Interface, comptime fmt: []const u8, args: anytype) void {
var buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, fmt, args) catch return;
self.*.log(msg);
}
};