Here is a dedicated write-up for those not familiar with @fieldParentPtr
-based interfaces, which is how the new reader/writer interface is implemented.
These two programs produce drastically different results:
bad_program.zig:
const std = @import("std");
pub fn main() !void {
const my_struct: struct { hello: []const u8 } = .{ .hello = "world" };
var buffer: [1024]u8 = undefined;
var file_writer = std.fs.File.stdout().writer(&buffer);
var writer_instance = file_writer.interface;
try std.json.Stringify.value(
my_struct,
.{ .whitespace = .indent_2 },
&writer_instance,
);
try file_writer.interface.flush();
}
$ zig run bad_program.zig
Oops! Looks like nothing is getting printed here!
good_program.zig:
const std = @import("std");
pub fn main() !void {
const my_struct: struct { hello: []const u8 } = .{ .hello = "world" };
var buffer: [1024]u8 = undefined;
var file_writer = std.fs.File.stdout().writer(&buffer);
const writer_ptr = &file_writer.interface;
try std.json.Stringify.value(
my_struct,
.{ .whitespace = .indent_2 },
writer_ptr,
);
try file_writer.interface.flush();
}
$ zig run good_program.zig
{
"hello": "world"
}
Great, we got the expected output!
Whats the problem?
Short answer:
The vtable in the writer interface is implemented using @fieldParentPtr
. When we made a copy of the interface with var writer_instance = file_writer.interface;
, @fieldParentPointer
uses a byte offset from our copy (called writer_instance
) instead of a byte offset from the interface
field within file_writer
. This gives us bogus results / undefined behavior. To get the correct behavior, we must be careful to not make a copy of the interface field, and only directly reference the interface field when passing it around.
Long Answer:
Lets add some type annotations and comments to good_program.zig
:
pub fn main() !void {
const my_struct: struct { hello: []const u8 } = .{ .hello = "world" };
var buffer: [1024]u8 = undefined;
// get a File that corresponds to stdout.
// On linux, "everything is a file", including stdout.
// Writing to this file shows text on the screen.
const stdout: std.fs.File = std.fs.File.stdout();
// Its confusing that both of these types are called Writer.
// Not much I can do about that.
// What we really care about is how `writer_ptr` is implemented.
var file_writer: std.fs.File.Writer = stdout.writer(&buffer);
const writer_ptr: *std.Io.Writer = &file_writer.interface;
try std.json.Stringify.value(
my_struct,
.{ .whitespace = .indent_2 },
writer_ptr,
);
try file_writer.interface.flush();
}
Lets dive into how writer_ptr
is implemented, after a few function calls, the .interface
field of our file_writer
is populated with the following function:
pub fn initInterface(buffer: []u8) std.Io.Writer {
return .{
.vtable = &.{
.drain = drain,
.sendFile = switch (builtin.zig_backend) {
else => sendFile,
.stage2_aarch64 => std.Io.Writer.unimplementedSendFile,
},
},
.buffer = buffer,
};
}
It constructs a vtable (a struct of function pointers), using the drain
implementation in std.fs.File.Writer
. drain
is what actually writes bytes to our stdout file. Here is drain
:
pub fn drain(io_w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize {
const w: *Writer = @alignCast(@fieldParentPtr("interface", io_w));
// lots more code after this...
The drain
implementation is given only a pointer to the Writer interface (std.Io.Writer
), but it needs access to the surounding std.fs.File.Writer
.
@fieldParentPtr("interface", io_w)
tells the compiler, "I promise this io_w
Iâm working with is the interface
field of a std.fs.File.Writer
, please compute a byte offset from the the address at io_w
to get to a std.fs.File.Writer
so I can manipulate it later in this function`.
Lets visualize this byte offset jump:
std.fs.File.Writer
might be arranged like this in memory:
pub const Writer = struct { // memory address 12
file: File, // memory address 12
// some more fields...
// ...
interface: std.Io.Writer, // memory address 40
}
The actual memory locations / alignment details donât matter, whatâs important to understand is that the compiler knows the memory layout of the std.fs.File.Writer
type. It knows that given the location of the interface
field at address 40, it can walk 28 bytes up to reach where the file
field is.
This is what @fieldParentPtr("interface", io_w)
is doing. We are telling the compiler: âWalk 28 bytes up, I promise you can reinterpret the memory at that location as a std.fs.File.Writer
.â.
The problem with bad_program.zig
is that we are breaking our promise to the compiler, we are making a copy of the interface, such that our interface (std.Io.Writer
) is no longer a member of a surrounding struct (std.fs.File.Writer
).
Here is bad_program.zig
again with comments:
const std = @import("std");
pub fn main() !void {
const my_struct: struct { hello: []const u8 } = .{ .hello = "world" };
var buffer: [1024]u8 = undefined;
var file_writer = std.fs.File.stdout().writer(&buffer);
// oops! I made a copy here.
// Now I have two instances of `std.Io.Writer`.
// I have an instance here (called "writer_instance"), and
// another instance stored in file_writer.interface.
var writer_instance: std.Io.Writer = file_writer.interface;
try std.json.Stringify.value(
my_struct,
.{ .whitespace = .indent_2 },
&writer_instance,
);
try file_writer.interface.flush();
}
The memory layout might look like this:
struct { // memory address 12
file: File, // memory address 12
// some more fields...
// ...
interface: std.Io.Writer, // memory address 40
}
writer_instance: std.Io.Writer, // memory address 42
So when std.json.Stringify.value()
attempts to use writer_instance
, it will compute a byte offset to some garbage location that shouldnât be interpretted as a std.fs.File.Writer
.
The solution is to not make copies of @fieldParentPointer
-based interfaces. As we demonstrated in good_program.zig
.