How much do you love, hate or love-hate this code?

I had a late night idea of how could one make using vtable based interfaces easier, as in nearly automatic.

So, I decided to just write this late night thought out until it compiled (can very much be improved in a few ways):

const std = @import("std");

const Interface = struct {
    object: *anyopaque,
    greet: *const fn (*anyopaque, struct{*std.Io.Writer}) std.Io.Writer.Error!void,

    pub fn call_greet(self: Interface, writer: *std.Io.Writer) std.Io.Writer.Error!void {
        return self.greet(self.object, .{writer});
    }
};

const Greeter = struct {
    name: []const u8,

    pub fn greet(self: Greeter, writer: *std.Io.Writer) std.Io.Writer.Error!void {
        try writer.print("Hello, {s}\n", .{self.name});
    }
};

const Fareweller = struct {
    name: []const u8,

    // yes, this works without the pub here
    fn greet(self: Fareweller, writer: *std.Io.Writer) std.Io.Writer.Error!void {
        try writer.print("Bye, {s}\n", .{self.name});
    }
};

fn adapt(I: type, O: type, object: *O) I {
    const fields = std.meta.fields(I);
    var interface: I = undefined;
    inline for (fields) |f| {
        if (comptime std.mem.eql(u8, f.name, "object")) continue;
        const ptr_info = @typeInfo(f.type);
        const info = @typeInfo(ptr_info.pointer.child);
        const params = info.@"fn".params;

        const Tuple = params[1].type.?;
        const oldArgsFields = @typeInfo(Tuple).@"struct".fields;
        comptime var argsTypes: [oldArgsFields.len+1]type = undefined;
        comptime {
            for (argsTypes[1..], oldArgsFields) |*t, arg_field| {
                t.* = arg_field.type;
            }
        }
        argsTypes[0] = O;
        const ArgsTuple = @Tuple(&argsTypes);

        const Helper = struct {
            pub fn call(o: *anyopaque, param: Tuple) info.@"fn".return_type.? {
                const Args = ArgsTuple;
                var args: Args = undefined;
                args[0] = @as(*O, @alignCast(@ptrCast(o))).*;
                inline for (0..param.len) |i| {
                    args[i+1] = param[i];
                }
                return @call(.auto, @field(O, f.name), args);
            }
        };
        @field(interface, f.name) = &Helper.call;
    }
    interface.object = object;
    return interface;
}

fn do(writer: *std.Io.Writer, objects: []const Interface) std.Io.Writer.Error!void {
    for (objects) |o| {
        try o.call_greet(writer);
    }
}

pub fn main(init: std.process.Init) !void {
    var buffer: [1024]u8 = undefined;
    var stdout_file: std.Io.File.Writer = .init(.stdout(), init.io, &buffer);
    const stdout = &stdout_file.interface;

    var greeter: Greeter = .{
        .name = "World",
    };
    var fareweller: Fareweller = .{
        .name = "World",
    };
    try do(stdout, &.{
        adapt(Interface, Greeter, &greeter),
        adapt(Interface, Fareweller, &fareweller),
    });

    try stdout.flush();
}

So, how much does anyone here love or hate this? Or maybe both?

I am very much in the last camp.
I love it since Greeter, Fareweller and Interface don’t need to know of each other.
I hate it since this is starting to become comptime abuse imo.

1 Like

Personally… Hate it. Sorry.

I don’t really see why it would be nicer to do this, rather than each type creating it’s own instance of the Interface type. It’s certainly more complex and harder to understand.

I also subscribe to “If you need polymorphism, have a think and make sure you need polymorphism”. Vtables are punishing on CPU branch predictors.

2 Likes