Why std.Io.Writer interface design is different from std.mem.Allocator interface in 0.15.1

I’m surprised and confused to see all vtable functions in std.Io.Writer interface taking pointer to *std.Io.Writer struct instead of it’s implementation i.e, *anyopaque.

// one of the function signature in 0.15.1's std.Io.Writer.VTable
drain: *const fn (w: *Writer, data: []const []const u8, splat: usize) Error!usize

// one of the function signature in 0.15.1's std.mem.Allocator.VTable
alloc: *const fn (*anyopaque, len: usize, alignment: Alignment, ret_addr: usize) ?[*]u8

What are the benefits of using this interface design approach compared to std.mem.Allocator ?

Also std.Io.Writer can lead to undefined behavior in most cases if the user forgets to take reference of the interface like below.

var stdout_buffer: [1024]u8 = undefined;
const stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
var stdout = stdout_writer.interface;
try stdout.print("Run `zig build test` to run the tests.\n", .{});

I understand that use of buffer above vtable or inside interface has benefits and I can implement the same using allocator interface design instead of std.Io.Writer design.

I’ve compared the target code for both of the designs and surprisingly allocator Interface gives better optimized code i.e, no vtable calls compared to std.Io.Writer design that has vtable calls, where buffer is above vtable for both of these interface designs.

Below target code prints Helloworld, for std.Io.Writer style design, Hello is filled until full buffer is reached and later each byte is filled into buffer and checked everytime if buffer is filled before printing to terminal.

In Allocator style design, whole buffer is filled in two instructions without any checks and printed to terminal.

//std.Io.Writer interface design
        mov     dword ptr [rbp - 48], 1819043144 //"Hell"
        mov     byte ptr [rbp - 44], 111 //"o"
        ............
        ............
        mov     byte ptr [rax], 119 //"w"
        ............
        ............
        mov     byte ptr [rax + rdx], 111 //"o"
        ............
        ............
        mov     byte ptr [rax + rdx], 114 //"r"
        
         

//Allocator Interface design
        mov     dword ptr [rbp - 8], 1819043144 //"Hell"
        mov     byte ptr [rbp - 4], 111 //"o"
        .............
        .............
        mov     dword ptr [rbp - 8], 1819438967 //"worl"
        mov     byte ptr [rbp - 4], 100 //"d"
        .............

Target code for both the designs can be found at https://zig.godbolt.org/z/f1h1rbEMW

Can anyone please explain why allocator design is superior to std.Io.Writer design ?

The allocator interface used to be implemented in the same way as the current Io.Writer interface. This interface changed in version 0.9, and a blog post at the time described why the new allocator interface was superior, citing its improved performance with LLVM’s devirtualization optimizations. I’m not sure if this issue persists during self-hosted compilation.

3 Likes

they solve for different things. the Writer interface is one piece of many that contributes to the new I/O interface: Zig's New Async I/O | Loris Cro's Blog

Btw, one possible reason why Io.Writer does not use an allocator-style interface is that the allocator interface is a purely abstract interface, whereas Io.Writer is trying to implement an inheritance-style interface, aiming to ensure that its implementation necessarily includes a buffer. This requirement led Io.Writer to adopt a @findParentPtr-style design.
I think there is indeed room for discussion regarding the details of this interface implementation.

2 Likes

‘Inheritance’ is might be mis understood here.

The implementations need to access the interfaces’ state, and even mutate it, to deal with buffering.

The easiest solution is to have the interface be a field of the implementation, which also removes the need to store a pointer to the implementations’ state, as it can calculate it from the interface pointer.

1 Like

In C language, embedding the inherited object into the implementation is indeed a classic implementation method of inheritance-based OO.

3 Likes

You’re right, it is a way to do inheritance. I said it wasn’t because it was thinking from the other direction without realising it.

But also ‘inheritance’ is not the best word to use given the amount of people new to this area, it can be too easily mis interpreted, regardless of if it is accurate.

2 Likes

I think this might involve a definition of the term “interface.” In my view, an “interface” is something separate from state. If something is considered an interface, then the interface itself should be stable and stateless; it’s the implementation behind the interface that has state.
From this perspective, in the example of Io.Writer, I consider *Io.Writer to be an interface, while Io.Writer itself is not an interface because it has state and is part of the implementation. This is why, in my opinion, its essence is closer to that of an inherited root type.

Many languages, that have interfaces as a feature, do not allow them to have state. But the reverse is also true.

Why restrict what you can do when there are clear benefits as shown by Reader and Writer, they are massively more performant, even against other languages’ equivalent if they have appropriate buffer sizes, but that’s a separate issue.

This sounds like you are trying to fit it into your existing understanding, without questioning if your understanding is correct.

Not saying it is incorrect, but I think you have baggage from other languages.

This is also arguing semantics, which is not particularly productive.

Yeah, even the “C way” of nesting a struct is closer to ‘composition’ than it is to ‘inheritance’, even with only one ‘component’ :wink:

1 Like

No, don’t get me wrong. I’ve never considered requiring a writer to have a buffer to be a bad decision. There’s nothing wrong with the idea. It’s just that I consider a buffer to be part of an implementation, not “part of an interface.” Interfaces are used to provide stable access to objects. For example, a pointer is the simplest interface. A stateful, non-copyable interface, on the other hand, isn’t an interface; it’s an implementation.

When you assume a type must contain a root implementation, you’re thinking in terms of inheritance, much like implementing a database object and assuming that any database object must have a primary key. An inheritance-style interface (a pointer to a root implementation, accessed through container_of and its polymorphic implementation) is a suitable implementation for this type of thinking.

Don’t let your preconceived notion of “inheritance is bad” prevent you from avoiding the fact that you’re using inheritance when thinking about a problem using it. If you’re using inheritance and it’s a good approach, then don’t let it get in your way; there’s nothing wrong with it. The OP simply suggests that the performance of the specific implementation of the inheritance interface might be a concern.

1 Like

I didn’t go into that as it was even further off-topic, but since you have brought it up;

the key difference is with inheritance is a Dog that inherits Animal IS an Animal, with composition a Dog contains Animal, it is not an Animal. It’s a big difference but easily overlooked, that translates practically to inheritance being a higher level concept and composition being quite fundamental.

Inheritance is mostly a fault of human thinking, all uses can be practically replaced by composition while making data relationships simpler.

In C, nested structures and container_of are classic examples of implementing interfaces through inheritance. A structure containing a pointer to another structure is an example of implementing a compositional approach.

1 Like

I was not saying you did, rather I was using it as an example of why you shouldn’t arbitrarily restrict what an interface can be. Viewing the buffer as not part of the interface here seems like such a mind bend, I can’t understand it. I do understand why you have your understanding, though.

I think the rest of this comment is addressed by my other comment

To be clear, I am not saying ‘inheritance bad’ rather it is different to composition, which more accurately describes what we do in zig and c, etc.

The problem here is not actually buffer (it is a slice, so it is actually stateless and safe to copy arbitrarily), but end. end is a stateful thing, which means that if it is treated as part of an interface, it cannot be copied.
Perhaps we can normalize the specific implementation to the Allocator-like type, because the @fieldParentPtr implementation does interfere with clarifying the concepts.

If we consider buffer and end to be part of the interface, and implement the interface like Allocator:

const Writer = struct {
    ptr: *anyopaque,
    vtable: *const VTable,
    buffer: []u8,
    end: usize = 0,
    const Vtable = struct {
        drain: *const fn (w: *Writer, data: []const []const u8, splat: usize) Error!usize,
        ...
    };
};
const FileWriter = struct {
    file: File,
    err: ?WriteError = null,
    mode: Writer.Mode = .positional,
    pos: u64 = 0,
    ...
}

Such a Writer is unusable. (To be precise, it cannot be used like Allocator. If you always use *Writer, it is still safe, but I will not call Writer an interface anymore.) The functions within vtb modify end, which means that such a Writer interface still lacks the ability to copy arbitrarily like the Allocator interface, which would result in state loss. Essentially, buffer and end are part of the implementation details here.

Btw, in this example, if we use *Writer instead of Writer as an interface like Allocator , it actually achieved a combined implementation, decoupling the specific implementation from the general implementation, allowing the specific implementation to exist independently of the general implementation.

This means that a FileWriter implementation can combine multiple buffers to implement multiple Writers at the same time. However, this is not always logically correct.

The current std.Io.Writer implementation follows a standard inheritance logic. Specific implementations cannot logically exist independently of the general implementation. Therefore, a FileWriter is guaranteed to support a specific number of Writers (one) from the outset. This isn’t necessarily a bad thing; in fact, it’s often logically correct.

If my understanding is right, Io.Writer interface provides a way for implementations to mutate Writer’s state and whereas allocator interface doesn’t.

I think I understand why you emphasize that interfaces can have state. This seems to be because in some inheritance-based OO frameworks, it’s common practice to refer to general implementations as interfaces.
In short, the interface I’m discussing here is something completely different. It emphasizes a stateless abstract contract that determines how to interact with a specific implementation. Since the Allocator interface fully aligns with this concept, I’m discussing this level of interface here, rather than referring to the general implementation as an interface.

Yes, and I also read your Godbolt code and noticed that you have some problems with the writegate API mock. Here is your mock:

const Writer = struct {
    buffer: []u8,
    tail: usize = 0,
    vtable: *const struct { flush: *const fn (ptr: *Writer, buffer: []const u8) void },
    pub fn write_byte(self: *Writer, byte: u8) void {
        if (self.tail == self.buffer.len) {
            self.vtable.flush(self, self.buffer);
            self.tail = 0;
        }

        self.buffer[self.tail] = byte;
        self.tail += 1;
    }
    pub fn flush(self: *Writer) void {
        self.vtable.flush(self, self.buffer[0..self.tail]);
    }
};

const File = struct { handle: std.posix.fd_t, writer: Writer };
fn flush(ptr: *Writer, buffer: []const u8) void {
    const self: *File = @fieldParentPtr("writer", ptr);
    _ = std.posix.write(self.handle, buffer) catch unreachable;
}

But in fact, the flush API does not require an additional input buffer (because it is already included in the Writer implementation)
The actual mock should look like this:

const Writer = struct {
    buffer: []u8,
    tail: usize = 0,
    vtable: *const struct { flush: *const fn (ptr: *Writer) void },
    pub fn write_byte(self: *Writer, byte: u8) void {
        if (self.tail == self.buffer.len) {
            self.vtable.flush(self);
            self.tail = 0;
        }

        self.buffer[self.tail] = byte;
        self.tail += 1;
    }
    pub fn flush(self: *Writer) void {
        self.vtable.flush(self);
    }
};

const File = struct { handle: std.posix.fd_t, writer: Writer };
fn flush(ptr: *Writer) void {
    const self: *File = @fieldParentPtr("writer", ptr);
    _ = std.posix.write(self.handle, ptr.buffer[0..ptr.tail]) catch unreachable;
}

Your mock of AllocatorInterface cannot implement this api.

Modify your flush API so that its buffer is not input from external parameters, but from the implementation taken over by Writer. This API implementation is consistent with the current Io.Writer.

I implemented two versions of the Allocator-style interface.

The first version is a non-inherited interface, meaning that buffer and tail are purely invisible internal implementations. In this version, write_byte has to be implemented in vtb, and each concrete writer implementation must reimplement it, which seems to be poorly inlined.

The second version of the Allocator-style interface combines the statelessness of the Allocator interface with the inheritance feature. It seems that although the internal implementation of this interface in vtb still uses @fieldParentPtr, it is still better optimized than the non-Allocator style interface.

1 Like