Why not just `.vtable = @ptrCast(vtable)`?

I’m getting a grasp of interfaces, and am deeply unwilling to have to write self = @ptrCast(@alignCast(ptr)) all over the place. There MUST be a better way, no?

( See also: https://www.youtube.com/watch?v=2Q8gB2OXB2E )
( See also (idea of casting the function): Vtable interfaces and the role of @ptrCast and @alignCast - #11 by efjimm )

So I watched the video above that neatly describes Zig interfaces, specifically std.mem.Allocator, and thought to myself:

“If I can cast *anyopaque to *T, then surely I can cast the function that receives *anyopaque to one that receives *T. And if I can cast the function, then surely I can cast the whole VTable containing the functions.”

Thus:

const std = @import("std");
const Allocator = std.mem.Allocator;
const print = std.debug.print;

pub fn main(init: std.process.Init) !void {
    var workers: [2]WorkerInterface = undefined;

    workers[0] = try .init(init.gpa, Repeater, .{ .repeat_count = 3 });
    workers[1] = try .init(init.gpa, Tagger, .{ .tag = "bold" });
    defer for (workers) |worker| worker.deinit();

    for (workers) |worker| {
        worker.doStringStuff("Hello");
        print("=> {d}\n", .{worker.doNumberStuff(123)});
    }
}

pub const WorkerInterface = struct {
    ptr: *anyopaque,
    vtable: *const VTable(anyopaque),

    fn VTable(T: type) type {
        return struct {
            // VTable methods defined by init():
            getGPA: *const fn (*T) Allocator,
            selfDestroy: *const fn (*T) void,

            // VTable methods defined by interface implementations:
            deinit: *const fn (*T) void,
            doStringStuff: *const fn (*T, []const u8) void,
            doNumberStuff: *const fn (*T, usize) usize,
        };
    }

    pub fn init(gpa: Allocator, T: type, params: T.Params) !@This() {
        const ptr = try gpa.create(T);
        ptr.params = params;
        ptr.gpa = gpa;
        try ptr.init();

        const vtable = try gpa.create(VTable(T));

        // VTable methods defined by init():
        const DefinedByInterface = struct {
            fn getGPA(t: *T) Allocator {
                return t.gpa;
            }
            fn selfDestroy(t: *T) void {
                t.gpa.destroy(t);
            }
        };
        vtable.getGPA = DefinedByInterface.getGPA;
        vtable.selfDestroy = DefinedByInterface.selfDestroy;

        // VTable methods defined by interface implementations:
        vtable.deinit = T.deinit;
        vtable.doStringStuff = T.doStringStuff;
        vtable.doNumberStuff = T.doNumberStuff;

        return .{
            .ptr = ptr,
            .vtable = @ptrCast(vtable),
        };
    }

    pub fn deinit(self: @This()) void {
        const gpa = self.vtable.getGPA(self.ptr);
        self.vtable.deinit(self.ptr);
        self.vtable.selfDestroy(self.ptr);
        gpa.destroy(self.vtable);
    }

    pub fn doStringStuff(self: @This(), string: []const u8) void {
        self.vtable.doStringStuff(self.ptr, string);
    }

    pub fn doNumberStuff(self: @This(), number: usize) usize {
        return self.vtable.doNumberStuff(self.ptr, number);
    }
};

pub const Repeater = struct {
    pub const Params = struct { repeat_count: usize };
    params: Params,
    gpa: Allocator,

    pub fn init(_: *@This()) !void {}

    pub fn deinit(_: *@This()) void {}

    pub fn doStringStuff(self: *@This(), string: []const u8) void {
        for (0..self.params.repeat_count) |_| print("{s}\n", .{string});
    }

    pub fn doNumberStuff(self: *@This(), number: usize) usize {
        for (0..self.params.repeat_count) |_| print("{d} ", .{number});
        print("\n", .{});
        return number * self.params.repeat_count;
    }
};

pub const Tagger = struct {
    pub const Params = struct { tag: []const u8 };
    params: Params,
    gpa: Allocator,

    pub fn init(_: *@This()) !void {}

    pub fn deinit(_: *@This()) void {}

    pub fn doStringStuff(self: *@This(), string: []const u8) void {
        print(
            "<{s} type=\"string\">{s}</{s}>\n",
            .{ self.params.tag, string, self.params.tag },
        );
    }

    pub fn doNumberStuff(self: *@This(), number: usize) usize {
        print(
            "<{s} type=\"number\">{d}</{s}>\n",
            .{ self.params.tag, number, self.params.tag },
        );
        return number;
    }
};

Is this sound?

Off topic - you can get rid of these (to me) obnoxious nested scrollbars that Discourse puts around code blocks by adding the following filter to uBlock Origin (or other places that accept the same syntax):

ziggit.dev##:is(pre, pre > code):style(max-height: unset !important;)

1 Like

I think casting a *const fn (*anyopaque) void to *const fn (*T) void and then calling it is illegal behaviour. Zig callconv(.auto) is entirely unspecified and there is no guarantee that *anyopaque and *T are passed in the same way.

1 Like

Something like this should work though

const std = @import("std");
const Allocator = std.mem.Allocator;

const VTable = struct {
    getGPA: *const fn (*anyopaque) Allocator,
    selfDestroy: *const fn (*anyopaque) void,

    // VTable methods defined by interface implementations:
    deinit: *const fn (*anyopaque) void,

    // ...
};

fn getVTable(T: type) *const VTable {
    const wrap = struct {
        fn getGpa(ptr: *anyopaque) Allocator {
            return T.getGPA(@ptrCast(@alignCast(ptr)));
        }
        fn selfDestroy(ptr: *anyopaque) void {
            return T.selfDestroy(@ptrCast(@alignCast(ptr)));
        }
        fn deinit(ptr: *anyopaque) void {
            return T.deinit(@ptrCast(@alignCast(ptr)));
        }
    };
    return &.{
        .deinit = wrap.deinit,
        .getGPA = wrap.getGpa,
        .selfDestroy = wrap.selfDestroy,
    };
}

You could also call the inner function with @call(.always_inline, func, .{@ptrCast(@alignCast(ptr))} if you don’t trust llvm to inline that

1 Like

The reason is relatively simple for why both are necessary.

@ptrCast changes the type of what the pointer is pointing to, but not the requirements the pointed to thing needs to fulfill, like for example alignment.
@alignCast changes the alignment requirement of what the pointer is pointing, but that’s it.

anyopaque has (by default) an alignment requirement of 1 byte and is it’s own type.
Whatever your vtable has as alignment requirement depends on the vtable itself, and since a vtable consists normally just of pointers, this depends on the target platform itself. For example on (most) 64bit platforms it’s going to be 8 bytes.

A pointer to anything with 8 byte alignment is surely also aligned to 1 byte, but not the other way around. Think of an aligned pointer always needing fulfill the condition pointer mod alignment = 0.

So, why does const anyopaque_ptr: *anyopaque = @ptrCast(&vtable) work?
Well, as far as the typesystem is concerned, &vtable fulfills anyopaque’s alignment requirement of 1 byte. So that’s fine.
Other than that, it just changes the type.
So everything is fine.

Why does const vtable_ptr: *const VTable = @ptrCast(anyopaque_ptr) not work?
As far as the typesystem is concerned, anyopaque_ptr does not fulfill vtable_ptr’s alignment requirement since 1 byte alignment is weaker than 8 byte.
So you need @alignCast to tell the compiler to trust you about the correct alignment (which gets additionally checked when safety checks are enabled).

But here’s a thing one could do: One can additionally put an alignment requirement into the pointer type and overwrite the default alignment.
const anyopaque_ptr: *align(8) anyopaque = @ptrCast(&vtable).
This means that anyopaque_ptr has now an 8 byte alignment requirement.
If VTable has a weaker one than that (e.g. 4 bytes on 32bit platforms), you now need to cast here, of course, but if the cast from anyopaque_ptr to a *VTable can now leave the @alignCast out, if *VTablehas the same or a weaker alignment requirement than *align(8) anyopaque.

Now as for why your code example doesn’t work:
The WorkerInterface has as fields an *anyopaque and a *const VTable(anyopaque), so two known types.
In the WorkerInterface.init function, you try to cast a *const VTable(T) to a *const VTable(anyopaque). This will only work if T == anyopaque since *const VTable(anyopaque) == *const VTable(anyopaque).
But if for example T == Repeater, *const VTable(Repeater) != *const VTable(anyopaque).
So that doesn’t work. Different types.

If you have more questions, feel free to ask.

Thanks for the filter snippet.

The example works. The question is whether

const vta: *const VTable(T) = ...;
const vtb: *const VTable(anyopaque) = @ptrCast(vta);

is a good and/or sound idea. (Written on the go, typos possible.)