Most people here probably already know how vtable interface and its variants work in Zig. It took me a while to grasp the mechanics of the different parts of an interface and how they tie together. This was mainly due to the many different flavors of dynamic dispatching plus the intermixing of generic/comptime and tagged unions in various blogs and articles. Now that I know the ins and outs, I want to write down the version that has work well for me. This gears more toward clarity than cleverness.
Great description!!!
Possibly it worth to add example of āclassicā VTABLE interface
Thanks for the compliment. The reason I donāt want to catalog every variation of the interface pattern out there is because putting more in one post just brings more confusion into the picture for new readers. That was my experience when I read up on the subject in blogs and posts. I want to explain one pattern and do it clearly. Hopefully once people get the fundamentals, itāll be easier for them to read up on other patterns. Cheers.
Some questions that I have:
Does the āclassicā VTABLE interface basically save memory for every instance? Are there any disadvantages, maybe that there is one more level of indirection? Is this a speed vs memory tradeoff? (Though I guess memory can impact speed too, due to cache sizes.)
Advantage- less memory per instance (2 pointers)
Disadvantage- additional inderect calls
Youāre right. If āclassic vtableā as in having vtable as a struct and with a pointer to it in the interface object, then yes it would save memory per interface object since the LoggerDelegate() is an inline comptime function and its returned implementation specific struct exists once in the programās static memory. The vtable pointer in the interface object pointing to it would share the same static memory.
For Logger kind of feature that doesnāt have many objects created, the extra memory is not an issue. For Shape kind of feature that could have many objects, the extra memory can be an issue. Maybe itās a topic for another blog.
Below is the āclassic vtableā pattern. Note that itās not really the āclassicā as in the Allocator such that the implementation hooks up the vtable. The vtable is still hooked up by the interface object. (I know itās a bit confusing.)
pub const Logger = struct {
impl: *anyopaque,
vtable: *const VTable,
const VTable = struct {
v_log: *const fn(*anyopaque, []const u8) void,
v_setLevel: *const fn(*anyopaque, usize) void,
};
pub fn implBy(impl_obj: anytype) Logger {
const delegate = LoggerDelegate(impl_obj);
return .{
.impl = impl_obj,
.vtable = &.{
.v_log = delegate.log,
.v_setLevel = delegate.setLevel,
},
};
}
pub fn log(self: @This(), msg: []const u8) void {
self.vtable.v_log(self.impl, msg);
}
pub fn setLevel(self: @This(), level: usize) void {
self.vtable.v_setLevel(self.impl, level);
}
};
Iām inclined to say the benefit of vtables is Dynamic Dispatch. Yes, that is also the disadvantage. You get less performance but more freedom in what can be used. Also why you would really only reach for them if you needed that flexibility.
Boilerplate: must manually define each vtable method and delegate.
I am not knowledgeable enough in Zig comptime programming but could you automate all this? To have something like:
fn createInterface(type: T) type {
// magic
}
and then:
createInterface(struct {
v_log: *const fn(*anyopaque, []const u8) void,
v_setLevel: *const fn(*anyopaque, usize) void,
});
It seems to me all the information are there to generate the appropriate boilerplate.
Good question. Iāve actually struggled for a while to try to get it working along the same thought. But Zig doesnāt support generating functions in comptime. The delegation functions for each implementation need to be generated. The public API methods need to be generated. Both wonāt work without function generation support in comptime. The codegen probably only work in build time as of now, i.e. having a tool to parse an āinterfaceā file and to generate the interface.zig file during build.
Yes of course, you canāt just loop and create function on the fly. Itās probably considered an anti-pattern in the zig mantra.
Thanks for your article.
Related thread: Generate functions during comptime
Iām not sure what the advantage of this polymorphic solution is. In my opinion, its essence is āeach object needs to copy a vtableā, which is similar to the following implementation:
const std = @import("std");
pub const Logger = struct {
v_log: *const fn (*Logger, []const u8) void,
v_setLevel: *const fn (*Logger, usize) void,
pub fn log(self: *Logger, msg: []const u8) void {
self.v_log(self, msg);
}
pub fn setLevel(self: *Logger, level: usize) void {
self.v_setLevel(self, level);
}
pub fn implBy(comptime T: type) Logger {
const impl = struct {
pub fn log(ptr: *Logger, msg: []const u8) void {
var self: *T = @fieldParentPtr("logger", ptr);
self.log(msg);
}
pub fn setLevel(ptr: *Logger, level: usize) void {
var self: *T = @fieldParentPtr("logger", ptr);
self.setLevel(level);
}
};
return .{
.v_log = impl.log,
.v_setLevel = impl.setLevel,
};
}
};
pub const DbgLogger = struct {
logger: Logger = Logger.implBy(DbgLogger),
level: usize = 0,
count: usize = 0,
pub fn log(self: *DbgLogger, msg: []const u8) void {
self.count += 1;
std.debug.print("{d}: [level {d}] {s}\n", .{ self.count, self.level, msg });
}
pub fn setLevel(self: *DbgLogger, level: usize) void {
self.level = level;
}
};
pub const FileLogger = struct {
logger: Logger,
file: std.fs.File,
pub fn init(path: []const u8) !FileLogger {
return .{
.file = try std.fs.cwd().createFile(path, .{ .read = false }),
.logger = Logger.implBy(FileLogger),
};
}
pub fn deinit(self: *FileLogger) void {
self.file.close();
}
pub fn log(self: *FileLogger, msg: []const u8) void {
self.file.writer().print("{s}\n", .{msg}) catch |err| std.debug.print("Err: {any}\n", .{err});
}
pub fn setLevel(self: *FileLogger, level: usize) void {
self.file.writer().print("== New Level {d} ==\n", .{level}) catch |err| std.debug.print("Err: {any}\n", .{err});
}
};
test "interface" {
var dbg_logger = DbgLogger{};
var logger1 = &dbg_logger.logger;
logger1.log("Hello1");
logger1.log("Hello2");
logger1.setLevel(2);
logger1.log("Hello3");
var file_logger = try FileLogger.init("log.txt");
defer file_logger.deinit();
var logger2 = &file_logger.logger;
logger2.log("Hello1");
logger2.setLevel(3);
logger2.log("Hello2");
logger2.log("Hello3");
const loggers = [_]*Logger{ logger1, logger2 };
for (loggers) |l|
l.log("Hello to all loggers");
}
This implementation maybe get better performance?
In the context of Zig I mostly saw the term vtable used as a comptime know constant pointer to a struct of readonly function pointers.
Basically I think we shouldnāt call these grab-bags of function pointers vtables, because they are even more dynamic than vtables (in the allocator interface) have been.
The main difference is that you canāt dynamically change where individual function pointers of a vtable point to, you only can point the vtable pointer to a new different collection of pointers, which are usually also placed in read only memory and not changed at runtime.
I think with vtables there is a bit more hope that the compiler can see the limited number of different implementations at compile time and somehow use that to generate better code.
And I also expect to see a fat pointer when people talk about vtable interfaces, not extra fat pointers that directly include multiple function pointers.
Normally the expectation is that because all the different vtables are comptime known that the compiler would be able to properly optimize those, without making the fat pointers bigger than 2 pointers.
Regarding the implementation in the blog post, here are some notes:
- donāt use
@This()
everywhere Don't `Self` Simple Structs! - Zig NEWS LoggerDelegate
andTPtr
are unnecessary if you instead @ptrCast the function pointers from the concrete implementation to*const fn(*anyopaque, ...) void
implBy
creates an interface value (using monomorphization) based on name matching (expecting the implementation to use the same names), so I renamed it tofromNamedMethods
const std = @import("std");
/// Logger interface
pub const Logger = struct {
impl: *anyopaque, // (1) pointer to the implementation
v_log: *const fn (*anyopaque, []const u8) void, // (2) vtable
v_setLevel: *const fn (*anyopaque, usize) void, // (2) vtable
// (3) Link up the implementation pointer and vtable functions
pub fn fromNamedMethods(impl: anytype) Logger {
const T = std.meta.Child(@TypeOf(impl));
return .{
.impl = impl,
.v_log = @ptrCast(&@field(T, "log")),
.v_setLevel = @ptrCast(&@field(T, "setLevel")),
};
}
// (4) Public methods of the interface
pub fn log(self: Logger, msg: []const u8) void {
self.v_log(self.impl, msg);
}
pub fn setLevel(self: Logger, level: usize) void {
self.v_setLevel(self.impl, level);
}
};
// use below
pub const DbgLogger = struct {
level: usize,
count: usize,
pub const init: DbgLogger = .{
.level = 0,
.count = 0,
};
pub fn log(self: *DbgLogger, msg: []const u8) void {
self.count += 1;
std.debug.print("{}: [level {}] {s}\n", .{ self.count, self.level, msg });
}
pub fn setLevel(self: *DbgLogger, level: usize) void {
self.level = level;
}
};
pub const FileLogger = struct {
file: std.fs.File,
pub fn init(path: []const u8) !FileLogger {
const f = try std.fs.cwd().createFile(path, .{ .truncate = false });
try f.seekFromEnd(0);
return .{ .file = f };
}
pub fn deinit(self: *FileLogger) void {
self.file.close();
}
pub fn log(self: *FileLogger, msg: []const u8) void {
self.file.writer().print("{s}\n", .{msg}) catch |err| std.debug.print("Err: {any}\n", .{err});
}
pub fn setLevel(self: *FileLogger, level: usize) void {
self.file.writer().print("== New Level {} ==\n", .{level}) catch |err| std.debug.print("Err: {any}\n", .{err});
}
};
pub fn main() !void {
var dbg_logger: DbgLogger = .init;
const logger1: Logger = .fromNamedMethods(&dbg_logger);
logger1.log("Hello1");
logger1.log("Hello2");
logger1.setLevel(2);
logger1.log("Hello3");
var file_logger: FileLogger = try .init("log.txt");
defer file_logger.deinit();
const logger2: Logger = .fromNamedMethods(&file_logger);
logger2.log("Hello1");
logger2.setLevel(3);
logger2.log("Hello2");
logger2.log("Hello3");
const loggers = [_]Logger{ logger1, logger2 };
for (loggers) |l|
l.log("Hello to all loggers");
}
But personally I would go with the vtable struct:
/// Logger interface
pub const Logger = struct {
impl: *anyopaque, // (1) pointer to the implementation
vtable: *const VTable,
pub const VTable = struct {
log: *const fn (*anyopaque, []const u8) void, // (2) vtable
setLevel: *const fn (*anyopaque, usize) void, // (2) vtable
};
// (3) Link up the implementation pointer and vtable functions
pub fn fromNamedMethods(impl: anytype) Logger {
const T = std.meta.Child(@TypeOf(impl));
return .{
.impl = impl,
.vtable = &.{
.log = @ptrCast(&@field(T, "log")),
.setLevel = @ptrCast(&@field(T, "setLevel")),
},
};
}
// (4) Public methods of the interface
pub fn log(self: Logger, msg: []const u8) void {
self.vtable.log(self.impl, msg);
}
pub fn setLevel(self: Logger, level: usize) void {
self.vtable.setLevel(self.impl, level);
}
};
For the FileLogger
I changed the init
function to:
const f = try std.fs.cwd().createFile(path, .{ .truncate = false });
try f.seekFromEnd(0);
return .{ .file = f };
.read
is already false by default, with these changes the file isnāt truncated and the write position is set to the end of the file, so that new logs are appended.
I guess it depends on the application whether you would want to append or truncate, but I think append is the saver default for logs.
Thatās an awesome usage of @fieldParentPtr
. This interface pattern works well.
Something that might stop me from adopting it are: the reference back to the interface inside the implementation type, and the hard coded field name in the interface to look up the āloggerā field from the implementation. It feels like both the interface and the implementation are tightly coupled pointing at each other.
Also an implementation struct can implement multiple interfaces, i.e. Logger, Comparable, Serializable, etc. All those need to be explicitly included in the implementation type, and all of the interface objects need to be instantiated whenever the implementation struct is created, rather than as needed.
It works and thereāre use cases for it. Itās good to know and kept in oneās toolbox.
Mildly related question - what are the performance implications when using vTables vs tagged unions vs anytype? The last two are compile time which in theory should be faster? Any resources to learn about it? Thanks
anytype
will win in any time of the day. Upon calling a function with a different type for the anytype
parameter, a new version of the function is created specifically for the type. Itās static dispatching.
Tagged union follows. The tag
in call(tag
: Tag) might be known in comptime and a specific version of the function can be generated in comptime.
vtable is the slowest because of pointer redirection (1 redirect or 2 redirects depending on implementation). Pointer jumping will most likely need to bring cold memory into L1/L2 cache.
Perfect - thanks!
I roughly understand your goal. You hope that the submodule does not contain the embedded content of the parent module, and multiple parent modules can generate interfaces based on the submodule.
If at the same time, you also hope that each method of the submodule can be flexibly modified dynamically at runtime, then I think your solution is quite valuable.
However, your implementation does not seem to involve the purpose of dynamically modifying interface methods at runtime. In this scenario, I recommend fat pointers. It was found that it was due to the backend optimization of LLVM and can have better performance in static interface scenarios. See this article.
Iām sorry. Iām quite confused. Most of Allocators (at least in 0.14) donāt use dynamically modifying vtables as stated in the article. They just use āconst struct valueā for their own version of the vtable, which is treated a comptime value and stored as a single copy in static memory. In reply #6 above, I put out a āfat pointerā version which uses the same āconst struct vtableā technique as in the Allocators, just all the changes are localized on the interface side.
Sorry if Iām too dense. I didnāt follow the evolution of the language as I joined the party late.
Thank you for the detail critique! You are the best. Iāll take the issues raised to heart.