Compiler-checked runtime interfaces with struct(interface) syntax

Zig std often uses a pattern of building a vtable to represent an interface at runtime.
This is used for things like std.Io for example.
The main problem with building an interface structure by hand is that:
It is a bit verbose to implement a vtable in a structure with correct logic from memory.
Handwritten interface implementations are not checked to adhere to the interface surface.

In Zig there already exist similar syntax to collection that give a contract for user.
The enum and union keywords.

const Tag = enum { a, b, c };

const Tagged = union(Tag) { a: u8, b: f32, c: bool };

const Untagged = union { a: u8, b: f32, c: bool };

Enum is more or less a collection of words representing numbers.
While union is just a collection.
Together they form a tagged union where the union is restricted by the enum.

This can be applied in a similar manner to interfaces.
Interface is a collection of function definitions.
While a struct is a collection.
Together we can restrict the struct to adhere to the interface.

const Reader = interface {
    fn read() void,
    fn readLength(length: usize) !void,
};

const ReaderImpl = struct(Reader) {
    pub fn read() void {...}
    pub fn readLength(length: usize) !void {...}
};

const WithouInterface = struct {...};

Making it match the enum syntax very closely.

Interface can only contain function definitions.
Interfaces is type checked by compiler, and the struct must contain all fn that the interface has or error.
Compiler generates vtable or other dispatch machinery needed for runtime interface use.
Interface fully restrict the public surface of the struct making it have no other public functions or fields then what the interface defines.
The struct non-public surface can be whatever needed to implement the interface’s demands.

The strictness in the struct only exposing interface publicly is that when used and accessed as an interface it would be impossible to reach pub functions outside the interface definition.
This exposes two public surfaces depending on struct is known or only interface is known.

Usage of the interface can happen at runtime and be swapped at runtime just like Allocators, Io and so on in std is.

fn useReader(reader: Reader) void {
    reader.read();
}

var file_reader = ReaderImpl{};

useReader(file_reader);

Related discussion:
Can we have compile-time, zero cost interfaces?

2 Likes

In this scheme, I’m not clear how you get methods: functions with a first parameter of the struct? interface? type.

I don’t think I’ve clearly understood what the problem you’re pointing out with status quo is?

If you hand-code a VTable struct, then when you produce an instance of that struct you must provide all of the corresponding function pointers or get a compile error. In other words, the benefit you describe exists today?

Oh, my bad.
I made the examples a bit sparse.

An interface can only contain method declarations that the implementing type is required to provide.
A struct that implements the interface is then checked against those method signatures.

@This() inside the interface refers to the type implementing that interface.

const std = @import("std");

const Calculation = interface{
    fn add(self: @This(), value: i32) i32,
};

const CalcImpl = struct(Calculation) {
    value: i32 = 0,

    pub fn add(self: @This(), value: i32) i32 {
        return self.value + value;
    }
};

test {
    const calc = CalcImpl{};
    try std.testing.expectEqual(1, calc.add(1));
}

I think my original was a bit poorly worded.

  1. I want to not have to type out boilerplate every time I use interfaces.
    I also do not remember how to boilerplate an interface fully, so I often stumble a bit halfway.
    Or I reach for documentation, copy-paste, and replace things just to get the boilerplate.
    It is a rather common structure and is a bit too verbose for my liking with a vtable.

  2. I want the compiler to understand that the struct is an interface implementation, and be able to make guarantees and optimizations relevant to such a structure.

  3. I want the struct using the interface to be rather strict.
    I do not fully see a point in letting the structure have a larger surface than the interface defines.
    It only leads to usage of the struct and passing it when it is meant to be used through the interface way.
    When the struct is passed as an interface, if the struct had other public methods, those would be unreachable.

  4. I also think it is a bit of a discoverability issue for new users if the default way is to do vtables.

Ahhhhh, I see, thanks for explaining. I think the discoverability issue is probably thought of as a feature rather than a bug: Zig appears to want to keep a certain amount of friction present around dynamic dispatch. I’m not sure if this is a function of me or the projects for which I use Zig for or what, but somehow as a result I mostly don’t use complex interfaces when I program in Zig.

I’m pretty sure the friction and boilerplate when it comes to interfaces is intended. You most typically don’t need dynamic dispatch and it can seriously introduce performance issues when overused. It’s usually sufficient to have comptime interfaces, which are basically just doing a bunch of comptime checks on @typeInfo(T). At least this is what Zig claims.

Technically, interfaces are not necessary in Zig, and a little bit of friction is ok.

However, using functions which take an anytype argument / vtables, is too error-prone while developing, at least in my experience after using Zig for 2 years now.

When I struggle with writing code, probably 50% is related to this single construction.

An interface, as described here and in older posts, would really help…

At least in many cases.

Looking closer at my mistakes, I guess 1/3 of them are related to the arguments of print/formatting functions like std.debug.print. A classical one is std.debug.print(“Your score is {d}\n”, score);

(forgetting .{…} ). Or forgetting the dot, sometimes.

An interface wouldn’t protect me from those.

The numbers are just guessed. I don’t take note of my mistakes in a list…

I think an issue I see here is that this would mean allowing type coercion from ReaderImpl to a newly-constructed Reader (which wouldn’t be a copy, it would hold a *ReaderImpl). Should only *ReaderImpl coerce to a new Reader? Should *const ReaderImpl coerce to a new Reader that has a *const ReaderImpl inside? Maybe it should coerce only to *const Reader?

Zig avoids this kind of magic-at-a-distance type coercion in basically all other cases, so this opens up a can of worms for it’s WYSIWYG goals.

An interface would have to be ignored by type coercion. One could think of it as “just documentation for the programmer” and maybe a hint for zls, but otherwise just syntactic sugar for anytype.