Eliminating the intrusive interface copy footgun in userland via an opaque representation

(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

8 Likes

I would prefer actual use of pub const Opaque = opaque {}, personally.

Yeah, but opaque {} can only be used with pointers and does not by itself take up any space in status quo (it cannot in fact be put into structs or variables). I believe there was a proposal to allow that, but it was rejected. Here, we need actual storage for the interface, but want to make its contents inaccessible in ways other than the intended API, so I opted for a byte array.

EDIT: It was probably this discussion on Ziggit, not a proposal, actually.

my thinking was that the creator of the implementation doesn’t need protections from themselves, what needs protection is that the interface does not get separated. that is, the interface should always be passed as a pointer and not as a struct.

i suppose unfortunately the only way to fix this is to make the reader or writer an opaque type, which loses many of the benefits to having the buffer above the vtable…

so maybe in the end i’m happy to let people make the mistake the small number of times they need to in order to learn lol

2 Likes

That is precisely my intention with this design. Instead of having the caller work with the interface field directly, risking that they copy it out of the parent struct, it introduces an interface() method that always returns a pointer to the interface, so that the obvious way of working with it becomes basically always valid (unless they add a dereference, which should look weird enough that they’ll avoid it by default). Notice how the main() function in the example never works with the animal field directly, it strictly relies on the interface() method.

They definitely don’t, and I don’t think they really get any this way. Rather, to their disadvantage, they have to add extra boilerplate to provide their callers with the proposed protection.

It looks like it defeats original purpose (my naive assumption) to not have an additional pointer stored.

There is no additional pointer stored, actually. The design is just… obfuscating the interface data from the POV of the type system (interpreting the struct as a byte array and vice versa). Then we just do a few pointer casts accordingly in a function that will most likely get inlined, and should thus ultimately resolve to nothing in machine code.

This would be nice to verify, though I realize such verification would not necessarily ensure the claim for all time.

Honestly, this footgun disappeared very quickly (in my own onboarding) and has a way of sticking out in one’s memory; I’m guessing that most people essentially never make the mistake once they’re aware (?) Still, for a beginner to not have to think about it in the first place is maybe appealing except… couldn’t one argue that it’s “meant” to be this way… not just an unfortunate side-effect footgun? I think I can see it both ways.

But I’m also missing something. I see how one could follow your pattern and make a nice intrusive-interface Animal solution, but you start out talking about Io.Reader and Io.Writer - you don’t seem to be suggesting an std change, changing those (and the many implementations like File.Reader … or are you? I.e., am I wrong: if std implemented this, then callers would not have to address the .interface(), and the std ‘footgun’ would be gone… right? When you said userland boilerplate, in the context of std, I was expecting to see boilerplate that one would add to avoid misusing std.Io.

1 Like

I like it. Feels easier to use than having to remember the rules about how the interface pointer is to be obtained not copied etc… don’t make me think about that when I’m just trying make hello world.

This seems like unnecessary compensation for two other issues:

  • no private fields in the language
  • a cultural aversion to naming fields to indicate they’re private (eg, _priv_interface)

I’m guessing the latter would prevent the problem most of the time, along with a method to get the interface pointer of course.

5 Likes

Ah, true, I should have been more explicit about this. I am indeed trying to suggest that something this might be applied to std.

Ah, no, I meant userland as in “this API design is possible in status quo Zig without language changes”. std is userland in that sense, although a bit special. The boilerplate here is on the side of the interface and its implementation. Callers only need an extra function call.

1 Like

That is actually a very valid point. It might just be that all we need is a naming convention.

Wouldn’t it be easier to just add a method to obtain the reader, like we do with allocator (and did with the old Reader)?

pub const MyReader = struct{
  interface: Reader,

  pub fn reader(self: *@This()) *Reader{
    return &self.interface;
  }
}
1 Like

Absolutely, if we treat interface as a private member. The problem I see with it is discoverability. A programmer may well look at the struct declaration, miss the reader() function and go straight after the interface. The point of my proposal is to use the type system to discourage going after the interface directly by basically adding a slight hurdle. The idea is for them to see the Opaque type and immediately tell themselves “Uh oh, something is fishy, I need to think about this before use”.

But yeah, maybe this solution is over-engineered. It was just an idea I’ve been mulling over the past days :slight_smile:

You might be aware of it already, but mentioning it just in case: I believe the current plan is to keep using the interface field and then rely on https://github.com/ziglang/zig/issues/2414 to catch illegal usage (like copying)

3 Likes

I am aware of it, but I still believe comptime safeguards are better than runtime ones.

3 Likes

Yeah, depends on the cost though and illegal behavior checks is frequently the zig-way of doing things. At any rate, 2414 catches a larger class of bugs in safe mode so would be good to have even if the interface footgun is solved otherwise. It’s certainly a frequently encountered issue :grimacing:

2 Likes

Absolutely, I do think #2414 will be a positive change. The proposed idea could work together with it very nicely, I think – prevent as many errors as viable at comptime, catch the rest of them at runtime. :slight_smile:

As for the cost, I have yet to check what codegen does with it, but I’m ideally hoping for zero at runtime.

1 Like

Indeed, I’ve wondered, “why not _interface and a getter fn?”, since pseudo-private underscore naming is so familiar to me from Python… but I didn’t realize that there was a cultural aversion to naming fields with underscores. I know I don’t see it a lot, but didn’t realize it was a baked-in aversion attitude. Can you point to the reasons for this? Or somebody’s sensible blog on it, or….? I agree with @spiffyk‘s concern about discoverability without a hint like a prefixed underscore.

1 Like

That is just my impression from reading posts here for a few years. I could be wrong!

Edit: Here’s one post I remembered: 0.15.1 Reader/Writer - #26 by andrewrk

Edit2: So perhaps one productive thing to do is agree on a convention for the text of the doc that should accompany a field that should not be copied.

2 Likes