Zig: Why does removing the type field from a vtable-based struct changes the behaviour

Hi @LoicCode, welcome to Ziggit!

I tried your second code example and depending on the Zig version I get different results.
On 0.15.2, I get a segfault. On 0.16.0-dev.1976+8e091047b it runs fine.
I think you are running into some illegal behavior that just "happenss’ to work fine depending on struct sizes and the stack.
That being said, I don’t think you want to go down this route.

This is probably part that is causing the issue. You stack allocate an implementation and the return a value that points at the stack. This becomes invalid memory after get_default returns.

Typically (in the std lib anyway), you want your concrete implementation to create the interface, not the other way around. So you want your DefaultScheduler to have a method that returns a Scheduler interface, not the other way around. The Scheduler interface needs an internal pointer, and you can’t get it from a function. You could, however, use a global constant for the scheduler for the default. This has it’s own caveats for use (may not be thread safe).

Here’s how I would try to rewrite it:

const std = @import("std");

pub const Scheduler = struct {
    ptr: *anyopaque,
    vtab: *const struct {
        start: *const fn (ptr: *anyopaque) void,
        stop: *const fn (ptr: *anyopaque) void,
    },
 

    pub fn start(self: Scheduler) void {
        self.vtab.start(self.ptr);
    }

    pub fn stop(self: Scheduler) void {
        self.vtab.stop(self.ptr);
    }
};

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

    pub fn start(ptr: *anyopaque) void {
        const self: *DefaultScheduler = @ptrCast(@alignCast(ptr));
        std.debug.print("Hidden start implementation: {s} \n", .{self.name});
    }

    pub fn stop(ptr: *anyopaque) void {
        const self: *DefaultScheduler = @ptrCast(@alignCast(ptr));
        const n = self.name;
        std.debug.print("Hidden stop implementation: {s} \n", .{n});
    }

    fn init() DefaultScheduler {
        return .{ .name = "DefaultScheduler" };
    }

    pub fn scheduler(self: *DefaultScheduler) Scheduler {
        return .{
            .ptr = self,
            .vtab = &.{
                .start = start,
                .stop = stop,
            },
        };
    }
};

pub fn main() void {
    var default = DefaultScheduler.init();
    const sched = default.scheduler();
    sched.start();
    sched.stop();
}

You’ll notice that the Default scheduler knows how to conform to the scheduler interface. You’ll see this pattern in the allocators (DebugAllocator.allocator) and readers and writers (File.reader).

5 Likes