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

Thank you all for sharing your inputs!

I’ve fall back on the following approach, so no more unexpected/unreliable result.

My approach is the following

const std = @import("std");

pub fn get_default() Scheduler(DefaultScheduler) {
    return Scheduler(DefaultScheduler).init(DefaultScheduler{});
}

pub fn Scheduler(comptime T: type) type {
    return struct {
        const Self = @This();
        implementation: T,

        pub fn start(self: Self) void {
            @field(T, "start")(self.implementation);
        }

        pub fn stop(self: Self) void {
            @field(T, "stop")(self.implementation);
        }

        pub fn init(impl: T) Self {
            return Self{
                .implementation = impl,
            };
        }
    };
}

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

    fn start(self: DefaultScheduler) void {
        std.debug.print("Hidden start implementation {s} \n", .{self.name});
    }

    fn stop(self: DefaultScheduler) void {
        std.debug.print("Hidden stop implementation {s} \n", .{self.name});
    }
};

I am curious to get your feedback :grinning_face:

When I am

I’ve the following output to in the console.

    const scheduler = quartz.scheduler.get_default();
    scheduler.start();
    scheduler.stop();

Hidden start implementation DEFAULT_SCHEDULER
Hidden stop implementation DEFAULT_SCHEDULER

1 Like

The literal syntax in the @field call isn’t doing anything for you. It’s precisely equivalent to

T.start(self.implementation);

Which should be preferred here.

The @field builtin can be called with a comptime-known string, which is pretty useful, but if you know the value of the string as well, you can simply use it with dot syntax.

2 Likes

Thank you very much! for the recommendation :grinning_face: I’ve updated the code

const std = @import("std");

pub fn get_default() Scheduler(DefaultScheduler) {
    return Scheduler(DefaultScheduler).init(DefaultScheduler{});
}

pub fn Scheduler(comptime T: type) type {
    return struct {
        const Self = @This();
        _implementation: T,

        pub fn start(self: Self) void {
            T.start(self._implementation);
        }

        pub fn stop(self: Self) void {
            T.stop(self._implementation);
        }

        pub fn init(impl: T) Self {
            return Self{ ._implementation = impl };
        }
    };
}

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

    fn start(self: DefaultScheduler) void {
        std.debug.print("Hidden start implementation {s} \n", .{self.name});
    }

    fn stop(self: DefaultScheduler) void {
        std.debug.print("Hidden stop implementation {s} \n", .{self.name});
    }
};

I’ve removed pub for start and stop functions for DefaultScheduler; so it cannot be called from “outside”

    const scheduler = quartz.scheduler.get_default();
    scheduler.start();
    scheduler.stop();
1 Like

For something like this I wouldn’t even have a generic wrapper, I’d just take the scheduler implementation directly in my functions as either S: type, scheduler: S or scheduler: anytype.

1 Like

Would you have a more details example?

From my perspective/problem; it is the only implementation which addresses Zig limitations.

Another use case of having interface, is for database. JDBC is a good example where you have consistent API calls to the DB. It helps to understand faster and avoid API to come up with their own flavors/objects.

I clearly don’t understand why Zig is such resistant of giving the freedom of using interface. Letting user to call directly the implementation is not always a good choice

“Interface” is generally used to refer to a runtime interface, this is not what you have, zig absolutely lets you do this, it’s just not a language feature for various reasons.
A generic type is a more accurate description of your example.

What limitations?

It will not give immediate errors if the implementation doesn’t implement a function correctly/at all until you try to call that specific function.

My point is, your generic type offers no value over using the implementation directly.

Ofc that changes if you add functionality that builds on top of what the implementation provides.

this is tangential to what i am talking about, you dont gain or lose this by using or not using an interface or generic type.

This keeps coming up, I consider it primarily a problem of pedagogy, or communication.

Zig does not have an interface keyword, but many languages do, or something else close to it. Naturally, new users want to know how to do the things they do using that keyword, in Zig.

We colloquially refer to the type-erased-pointer pattern (Allocator etc) as “interfaces” but that’s ingroup language and not really accurate. In fact, most use cases for interface are better solved in some other way in Zig: comptime genericity and unions being the top two.

But coming away with the impression that this is how you “do interfaces” in Zig is pretty natural under the circumstances. But it’s if anything the opposite: one shouldn’t favor the type-erasure + vtable pattern unless it’s really needed, and it usually is not.

1 Like