(EDIT - 2 days later: I actually don’t think this is a good idea anymore, for reasons stated in a later comment)
Hi, I’ve been thinking a bit about the footgun with intrusive interfaces (like std.Io.Reader and std.Io.Writer), wherein beginners tend to accidentally copy the interface into a variable instead of taking a pointer, causing them to corrupt their memory because of a “small” mistake. I’ve come up with a possible userland solution that eliminates the problem by making the obvious way to use the interface also the correct one, including storing it in a variable. The setup is admittedly a little boilerplate-y, but seems to get the job done and once you get all the boilerplate in place, I don’t think it has any real disadvantages (? - feel free to point out any).
The principle is a grug-brained one, really. Instead of having implementations store the interface itself, we provide an alternative opaque representation of it that cannot be used directly, because it only exposes itself as an aligned array of bytes (which however has the contents of the real interface struct) and have them store that. Then we take advantage of Zig’s syntax that allows calling methods on pointers transparently: we provide an interface() method that casts the pointer to the byte array back to a pointer to the interface, et voila – we get basically the same API shape we had before, just a tiny bit safer for beginners to use.
Here is a classic Animal example to demonstrate how this could work:
pub fn main() void {
var orange_cat: Cat = .init(.orange);
const o = orange_cat.interface(); // Storing in a variable is not a footgun anymore!
o.makeSound();
o.makeSound();
var tri_cat: Cat = .init(.tri_colored);
const t = tri_cat.interface();
t.makeSound();
o.makeSound();
var dog: Dog = .init();
dog.interface().makeSound();
dog.interface().makeSound();
o.makeSound();
dog.interface().makeSound();
}
pub const Animal = struct {
num: u32 = 1,
make_sound: *const fn (*Animal) void,
pub fn makeSound(self: *Animal) void {
self.make_sound(self);
self.num += 1;
}
pub fn parentPtr(
self: *Animal,
comptime P: type,
comptime field_name: []const u8,
) *P {
const self_bytes: *align(@alignOf(Animal)) [@sizeOf(Animal)]u8 = @ptrCast(self);
const self_opaque: *Opaque = @fieldParentPtr("data", self_bytes);
return @fieldParentPtr(field_name, self_opaque);
}
pub const Opaque = struct {
data: [@sizeOf(Animal)]u8 align(@alignOf(Animal)),
pub fn init(animal: Animal) Opaque {
return .{ .data = std.mem.asBytes(&animal).* };
}
pub fn cast(self: *Opaque) *Animal {
return @ptrCast(&self.data);
}
};
};
pub const Cat = struct {
color: Color,
animal: Animal.Opaque,
pub fn init(color: Color) Cat {
return .{
.color = color,
.animal = .init(.{ .make_sound = &makeSound }),
};
}
pub fn interface(self: *Cat) *Animal {
return self.animal.cast();
}
fn makeSound(animal: *Animal) void {
const cat = animal.parentPtr(Cat, "animal");
std.debug.print("cat({t}): Meow {d}\n", .{ cat.color, animal.num });
}
pub const Color = enum { black, orange, tri_colored };
};
pub const Dog = struct {
animal: Animal.Opaque,
pub fn init() Dog {
return .{ .animal = .init(.{ .make_sound = &makeSound }) };
}
pub fn interface(self: *Dog) *Animal {
return self.animal.cast();
}
fn makeSound(animal: *Animal) void {
std.debug.print("dog: Woof {d}\n", .{animal.num});
}
};
const std = @import("std");
Output:
cat(orange): Meow 1
cat(orange): Meow 2
cat(tri_colored): Meow 1
cat(orange): Meow 3
dog: Woof 1
dog: Woof 2
cat(orange): Meow 4
dog: Woof 3
EDIT: Clarification