0.15.1 Reader/Writer

Or maybe is it possible to enforce a parentPtr: *ParentType field on types that could be nested, assuming that it’s possible for the child to know the type of the parent when it’s attached to it. This field would be set after parent initialization and would make it possible to use copies of the child, since parentPtr would still work as long as the parent is still in scope.

I can’t imagine how this could work technically.

This works for me:

const std = @import("std");

const Interface = struct {
    parentPtr: usize = undefined,

    pub fn meow(i: *Interface, comptime T: type) void {
        const parent: *T = @ptrFromInt(i.parentPtr);
        std.debug.print("Hi from interface! I'll let my parent do the talk.\n", .{});
        parent.meows();
    }
};

const Parent = struct {
    interface: Interface,

    pub fn init() Parent {
        return .{
            .interface = Interface{},
        };
    }

    pub fn meows(p: *Parent) void {
        _ = p;
        std.debug.print("MEOWWWWW\n", .{});
    }

};

pub fn main() !void {
    var cat = Parent.init();
    cat.interface.parentPtr = @intFromPtr(&cat);
    cat.meows();
    cat.interface.meow(Parent);
    var ifc = cat.interface;
    ifc.meow(Parent);
}

output:

MEOWWWWW
Hi from interface! I'll let my parent do the talk.
MEOWWWWW
Hi from interface! I'll let my parent do the talk.
MEOWWWWW

Maybe the Interface struct can be generated at compile time so that you can actually set .parentPtr type to the type of the parent using generics, I don’t know if it’s possible.

I think this doesn’t suit to the idea of the interface in the std lib, because one of its pros is that the parent pointer is zero size at runtime, actually just a constant offset computed at comptime using @fieldParentPtr.

3 Likes

Yes it would increase the size of the interface by usize, but it would avoid the need of @fieldParentPtr that causes this kind of bugs. Anyway it’s just an idea.

Alright, I agree that quote was taken out of context and is not as bad as it looks on it’s own, I apologise and that was not my intent.

I think “condescending” would be a more accurate descriptor of how the message read to me.

*anyopaque is a type erased pointer, preferable over an integer.

Allocator uses that to point to the implementation state, and it’s also the reason people haven’t had this foot gun with it, because it requires a method to create an instance of the interface.

two problems here, reader/writer have a state that the implementation will use, requiring it be a field of the implementation and as your example demonstrates it’s hard to get a pointer in an initialisation function, you chose to push that on the caller, which is no better than what we have now as the caller needs to know that they have to set the parent pointer.

A much better solution would be to take a pointer to self as an arg to the init function requiring a variable to exist as temporaries are always const.

The downside is it makes creating one more verbose, but I think that’s better than the foot gun caused by the convenience of the status quo.

It also doesn’t require the interface to have a pointer to the parent/implementation state.

1 Like

This also seems to work and it doesn’t require passing types, nor pointer casts. So there’s only one additional step needed (undefined first, initalization later).

Edit: I tried with *anyopaque but I couldn’t come up with anything working. Maybe with anyopaque it’s possible to avoid generics, if you know how to do it.

const std = @import("std");

fn Interface(comptime T: type) type {
    return struct {
        const Self = @This();
        
        parentPtr: *T,

        pub fn init(ptr: *T) Self {
            return .{
                .parentPtr = ptr,
            };
        }
        
        pub fn meow(self: *Self) void {
            std.debug.print("Hi from interface! I'll let my parent do the talk.\n", .{});
            self.parentPtr.meows();
        }
    };
}

const Parent = struct {
    interface: Interface(Parent),

    pub fn init(self: *Parent) void {
        self.interface = Interface(Parent).init(self);
    }

    pub fn meows(p: *Parent) void {
        _ = p;
        std.debug.print("MEOWWWWW\n", .{});
    }

};

pub fn main() !void {
    var cat: Parent = undefined;
    cat.init();
    cat.meows();
    cat.interface.meow();
    var ifc = cat.interface;
    ifc.meow();
}

dont try to fuse generics and type erased interfaces, they are fundamentally opposing concepts.

const std = @import("std");

const Interface = struct {
    const Self = @This();

    parent_ptr: *anyopaque,
    meow_fn: *const fn (*anyopaque) void,

    pub fn meow(self: *Self) void {
        std.debug.print("Hi from interface! I'll let my parent do the talk.\n", .{});
        self.meow_fn(self.parent_ptr);
    }
};

const Parent = struct {
    interface: Interface,

    pub fn init(self: *Parent) void {
        self.interface = .{
            .parent_ptr = self,
            .meow_fn = meow_impl,
        };
    }

    pub fn meows(p: *Parent) void {
        _ = p;
        std.debug.print("MEOWWWWW\n", .{});
    }

    fn meow_impl(p: *anyopaque) void {
        const parent: *Parent = @ptrCast(@alignCast(p));
        parent.meows();
    }
};

pub fn main() !void {
    var cat: Parent = undefined;
    cat.init();
    cat.meows();
    cat.interface.meow();
    var ifc = cat.interface;
    ifc.meow();
}

Basically the same kind of interface as Allocator, though it has an additional level of indirection of a pointer to a struct with the function pointers to reduce the size of the struct.

fyi: the separate impl function is just so you can still parent.meows().

3 Likes

Works great!

Yes I suspected that they would end up being totally different types, that’s why I wrote that it ‘seemed’ to work. Your version is definitely better.

Edit: does Allocator solve the need of two-steps initialization somehow?

Allocator, and the example I provide, don’t need to be fields of the implementation as they are given a pointer to it and can instead be created on demand with a function removing this problem.

    pub fn interface(self: *Parent) Interface {
        return .{
            .parent_ptr = self,
            .meow_fn = meow_impl,
        };
    }

Reader/Writer don’t have a pointer to the implementation state, because the implementation often needs to mutate the interface’s state and so it is often a field of the implementation, which means you can calculate the implementation pointer from the interface pointer.

You mean that the parent of the interface can change? If so, parentPtr could be just reassigned, or not?

The main difference between 1. Allocator style interface and 2. Reader/Writer is that with 1. only the implementation needs a stable memory address while the interface can be copied around (because it only contains pointers) and it doesn’t have anything above the vtable.
With 2. the implementation contains the interface and you are only allowed to pass around pointers to the interface, because it has data above the vtable.

The whole point of interfaces is so that you can have different implementations (sometimes referred to as parent in the context of the above conversation) while still using something that looks the same (the interface).

Adding a pointer to the implementation to the interface would essentially change the @fieldParentPrt based interface to one that only seems to resemble an Allocator style interface that has a fatpointer (pointer to vtable and implementation), until you look at something like std.Io.Reader and see that it also contains other fields that aren’t pointers like .seek, .end and .buffer.len (the len part of the buffer slice), which prevent this interface from becoming an extra-fatpointer that could be moved around as a value. Because if you did that then you would get really nasty usererrors in the form of I wrote something, moved the interface to some other function and then it overwrote what I just wrote before. (To fix that you would need an extra indirection, but 2. already has that because you are supposed to use *Io.Reader as the thing you can pass around)

I think the brainstorming of different solutions is well meaning, but I am pretty sure that these tradeoffs were already considered, especially since 2. has significant benefits for making the hot code path more efficient and the only cost is telling people to not copy the interface as a value or move it around. (and once the safety checks get implemented the safety check will do that)

I also think that if people used the time spend talking about the changes, by instead reading and understanding the standard library code of these interfaces, then their concerns would mostly disappear.

2 Likes

… instead reading and understanding the standard library code of these interfaces, …

… or just get used to it without fully understanding the implementation in the std lib.

Honestly, with 42 years of programming experience, I’m still struggling with that, so I’ll do what most young programmers do these days and just copy and adapt code without fully understanding it.

In a perfect world, it shouldn’t be necessary to read the implementation code, and the documentation should suffice instead.

However, when you’re using a pre 1.0 language, you can’t expect that.

But I’m pretty sure tutorials, good examples, and documentation will emerge sooner or later, so let’s take it as it is for now and get used to it.

For me Zig is the first language I have encountered, where I actually recommend reading the standard library code, because it is far more readable than you would expect (if you have encountered other less readable ones) and you often end up learning things about Zig itself and useful ways to accomplish things with Zig.

9 Likes

Same here… Zig just uses sometimes a sharper or different angle. I just have to grasp these ideas and getting used to them.

Build zls yourself and use so that you’ll get proper info and error messages. Currently i am doing that