New approach to interfaces?

I’m still relatively new to Zig, but I’ve not seen this interface pattern before. I’ve not actually used it in practice, but am interested to hear opinions on whether this is a good approach or not.

I see the advantages as follows:

  1. All of the logic is in the interface, nothing in the implementers.
  2. Implementers can take a self param of their own type, rather than anyopaque and casting it.

Disadvantages:

  1. Possibly an extra function call? Maybe it gets inlined?
  2. Slightly more awkward syntax than calling .allocator() or .reader()

See below for an implementation of an allocator-style interface.

MyInterface implements the generic alloc and free vtable functions as would be typical for a zig fat pointer interface.

My addition is an init function that creates the functions to cast the *anyopaque to the correct type, rather than have the implementing function do it. (There is probably a better name than init for this).

To create an interface off an implementation:

    var imp: MyImpl = .init();
    var i = MyInterface.init(MyImpl, &imp);

The full proof of concept is below. It’s a small change, but I think it could be good approach to a Reader-style interface, which can wrap any struct that provides a read() implementation.

Interested to hear people’s thoughts.


const MyInterface = struct {
    ptr: *anyopaque,
    vtable: *const VTable,

    pub const VTable = struct {
        alloc: *const fn (*anyopaque, len: usize) []u8,
        free: *const fn (*anyopaque, mem: []u8) void,
    };

    pub fn alloc(i: MyInterface, len: usize) []u8 {
        return i.vtable.alloc(i.ptr, len);
    }

    pub fn free(i: MyInterface, mem: []u8) void {
        return i.vtable.free(i.ptr, mem);
    }

    pub fn init(comptime T: type, instance: *T) MyInterface {
        if (std.meta.hasFn(T, "alloc") and
            std.meta.hasFn(T, "free"))
        {
            return .{
                .ptr = instance,
                .vtable = &.{
                    .alloc = struct {
                        fn alloc_trampoline(ptr: *anyopaque, len: usize) []u8 {
                            const self: *T = @ptrCast(@alignCast(ptr));
                            return self.alloc(len);
                        }
                    }.alloc_trampoline,
                    .free = struct {
                        fn free_trampoline(ptr: *anyopaque, mem: []u8) void {
                            const self: *T = @ptrCast(@alignCast(ptr));
                            return self.free(mem);
                        }
                    }.free_trampoline,
                },
            };
        } else {
            @compileError(std.fmt.comptimePrint("{s} must supply alloc(self, len) and free(self, mem)", .{@typeName(T)}));
        }
    }
};

pub const MyImpl = struct {
    gpa: std.heap.DebugAllocator(.{}),
    pub fn init() MyImpl {
        const gpa: std.heap.DebugAllocator(.{}) = .init;
        return .{
            .gpa = gpa,
        };
    }

    pub fn alloc(self: *MyImpl, len: usize) []u8 {
        std.debug.print("alloc\n", .{});
        return self.gpa.allocator().alloc(u8, len) catch unreachable;
    }

    pub fn free(self: *MyImpl, mem: []u8) void {
        std.debug.print("free\n", .{});
        return self.gpa.allocator().free(mem);
    }
};

pub fn main() !void {
    std.debug.print("start.\n", .{});
    var imp: MyImpl = .init();
    var i = MyInterface.init(MyImpl, &imp);

    const a = i.alloc(10);
    i.free(a);
    std.debug.print("done.\n", .{});
}

const std = @import("std");

As I was looking to refine this further, I found interfaces.zig which takes a similar, but much more complete approach.

I made something similar a while ago (the dynamic dispatch was inspired by interfaces.zig) and I’ve kept it up to date with current Zig. It’s mostly a fun experiment though, I think the current Zig standard library approach (e.g. Allocator) is the way to go. I believe there are improvements to the std Reader/Writer interfaces already in the works.

Thanks that was interesting to look through.

The full “interface library” style approach does seem like overkill, but this small change to the existing pattern, which just implements external creation of the vtable could be useful when you don’t have control over the underlying types, so you can’t embed the interface/vtable within in them.

I also like that I can use the implementation classes directly in place of the interface. i.e. be able to call read() directly on the implementation struct, rather than readImpl() or reader().read() or similar. But that is just a preference.