Non-intrusive vtable
2024-10-29
const Control = union(enum) {
button: *Controls.Button,
menubar: *Controls.Menubar,
menu: *Controls.Menu,
menu_item: *Controls.MenuItem,
editor: *Controls.Editor,
fn generation(self: Control) usize {
return switch (self) {
inline else => |c| c.generation,
};
}
fn setGeneration(self: Control, n: usize) void {
switch (self) {
inline else => |c| c.generation = n,
}
}
fn deinit(self: Control) void {
switch (self) {
inline else => |c| c.deinit(),
}
}
};
I’m calling this thing a “non-intrusive vtable”. A brief description for the less familiar with Zig.
In short, we have a tagged union, Control
, that can store a pointer to one
of the Controls
types listed. Because it’s a tagged union, the variable
itself stores both the pointer as well as a “tag” value indicating which of the
variants is being stored, so there’s no type confusion.
(One of these is unfortunately still 16 bytes on 64-bit systems: the tag, which could theoretically be stored in 3 bits, nonetheless has a full 64 bits allocated to it, because the payload that follows is a pointer, which must be aligned. ¯\_(ツ)_/¯)
In regular Zig code you can switch
on the value to get at the payload:
var b: *Controls.Button = undefined; // pretend we have one
var c: Control = .{ .button = b };
switch (c) {
.button => |p| {
// this branch will run, with p == b.
},
.menubar => |mb| {
// won't run in this case, but you get the idea.
},
else => {
// mandatory default if all cases aren't handled explicitly!
},
}
Now, these Controls
types all carry a generation: usize
member. One way of
getting the generation of an arbitrary Control
would be this:
fn getControlGeneration(c: Control) usize {
return switch (c) {
.button => |b| b.generation,
.menubar => |mb| mb.generation,
.menu => |m| m.generation,
.menu_item => |mi| mi.generation,
.editor => |e| e.generation,
};
}
This works fine, and is probably optimal. But what’s even more optimal is letting comptime do the codegen for you. Returning to the definition above, we find:
fn generation(self: Control) usize {
return switch (self) {
inline else => |c| c.generation,
};
}
inline else
is inline
in the same way inline for
and inline while
are. For every unhandled case, a prong is unrolled. This means the body must
compile for each possible capture (i.e. each payload type). (You can of course
do comptime calls here, to do different things with different kinds of payloads,
though please consider your complexity budget!)
In this way, we create dispatch functions that encode the knowledge of (biggest air quotes in the world) “all their subtypes’ implementations”. Ha ha ha.