``` Zig 的新作家 ```
Zig's New Writer

原始链接: https://www.openmymind.net/Zigs-New-Writer/

## Zig 的 Io 命名空间重构:概要 (2025年7月) Zig 的 Io 命名空间正在经历重大变化,为异步支持铺平道路。 这其中一个关键部分是对 Writer 和 Reader 接口的改进,重点在于提高易用性和性能。新的 `std.Io.Writer` 接口围绕一个 `drain` 函数展开,该函数接受字符串切片的数组和一个 `splat` 参数,以处理缓冲写入。 与简单的 `write` 方法不同,writer 现在在内部管理缓冲。为了利用这一点,像 `file.writer()` 这样的函数需要提供一个缓冲区——即使是一个空缓冲区也可以禁用缓冲。 `drain` 的常见实现只是调用现有的 `writeAll` 函数。 此次更新引入了具体 writer(如 `File.Writer`)和 `std.Io.Writer` *接口* 之间的区别,后者通过 `.interface` 字段访问。迁移需要调整旧代码;像 `std.fmt.formatIntBuf` 这样的函数被 `Writer` 接口上的方法所取代,通常需要一个 `Writer.fixed()` 包装器。旧的 writer 可能需要一个 `adaptToNewApi` 方法才能与新系统集成。 虽然比以前的状态有所改进,但作者质疑缓冲是否应该内置到 Writer 接口中,并建议基于组合的方法(如 BufferedReader/BufferedWriter)可能更灵活和一致。

一场 Hacker News 的讨论集中在 Zig 编程语言中的新型写入器实现上。一位评论者 mishafb 同意对写入器接口缺乏组合性的担忧,特别是关于缓冲的问题。 目前,Zig 写入器要求开发者自行管理缓冲以获得与系统调用最佳性能,但将缓冲设为可选会因 vtable 查找而降低速度。这位评论者希望未来能利用 Zig 的 comptime 能力来解决这个问题。理想的解决方案是允许实现能够在*编译时* 知道支持哪些特性(例如缓冲或零拷贝访问),并据此进行优化,并在未知具体特性时回退到通用的包装实现。 讨论承认 Zig 具有许多潜在的开发方向,并强调了该语言仍在不断发展。
相关文章

原文

Jul 17, 2025

As you might have heard, Zig's Io namespace is being reworked. Eventually, this will mean the re-introduction of async. As a first step though, the Writer and Reader interfaces and some of the related code have been revamped.

This post is written based on a mid-July 2025 development release of Zig. It doesn't apply to Zig 0.14.x (or any previous version) and is likely to be outdated as more of the Io namespace is reworked.

Not long ago, I wrote a blog post which tried to explain Zig's Writers. At best, I'd describe the current state as "confusing" with two writer interfaces while often dealing with anytype. And while anytype is convenient, it lacks developer ergonomics. Furthermore, the current design has significant performance issues for some common cases.

The new Writer interface is std.Io.Writer. At a minimum, implementations have to provide a drain function. Its signature looks like:

fn drain(w: *Writer, data: []const []const u8, splat: usize) Error!usize

You might be surprised that this is the method a custom writer needs to implemented. Not only does it take an array of strings, but what's that splat parameter? Like me, you might have expected a simpler write method:

fn write(w: *Writer, data: []const u8) Error!usize

It turns out that std.Io.Writer has buffering built-in. For example, if we want a Writer for an std.fs.File, we need to provide the buffer:

var buffer: [1024]u8 = undefined;
var writer = my_file.writer(&buffer);

Of course, if we don't want buffering, we can always pass an empty buffer:

var writer = my_file.writer(&.{});

This explains why custom writers need to implement a drain method, and not something simpler like write.

The simplest way to implement drain, and what a lot of the Zig standard library has been upgraded to while this larger overhaul takes place, is:

fn drain(io_w: *Writer, data: []const []const u8, splat: usize) !usize {
    _ = splat;
    const self: *@This() = @fieldParentPtr("interface", io_w);
    return self.writeAll(data[0]) catch return error.WriteFailed;
}

We ignore the splat parameter, and just write the first value in data (data.len > 0 is guaranteed to be true). This turns drain into what a simpler write method would look like. Because we return the length of bytes written, std.Io.Writer will know that we potentially didn't write all the data and call drain again, if necessary, with the rest of the data.

If you're confused by the call to @fieldParentPtr, check out my post on the upcoming linked list changes.

The actual implementation of drain for the File is a non-trivial ~150 lines of code. It has platform-specific code and leverages vectored I/O where possible. There's obviously flexibility to provide a simple implementation or a more optimized one.

Much like the current state, when you do file.writer(&buffer), you don't get an std.Io.Writer. Instead, you get a File.Writer. To get an actual std.Io.Writer, you need to access the interface field. This is merely a convention, but expect it to be used throughout the standard, and third-party, library. Get ready to see a lot of &xyz.interface calls!

This simplification of File shows the relationship between the three types:

pub const File = struct {

  pub fn writer(self: *File, buffer: []u8) Writer{
    return .{
      .file = self,
      .interface = std.Io.Writer{
        .buffer = buffer,
        .vtable = .{.drain = Writer.drain},
      }
    };
  }

  pub const Writer = struct {
    file: *File,
    interface: std.Io.Writer,
    

    fn drain(io_w: *Writer, data: []const []const u8, splat: usize) !usize {
      const self: *Writer = @fieldParentPtr("interface", io_w);
      
    }
  }
}

The instance of File.Writer needs to exist somewhere (e.g. on the stack) since that's where the std.Io.Writer interface exists. It's possible that File could directly have an writer_interface: std.Io.Writer field, but that would limit you to one writer per file and would bloat the File structure.

We can see from the above that, while we call Writer an "interface", it's just a normal struct. It has a few fields beyond buffer and vtable.drain, but these are the only two with non-default values; we have to provide them. The Writer interface implements a lot of typical "writer" behavior, such as a writeAll and print (for formatted writing). It also has a number of methods which only a Writer implementation would likely care about. For example, File.Writer.drain has to call consume so that the writer's internal state can be updated. Having all of these functions listed side-by-side in the documentation confused me at first. Hopefully it's something the documentation generation will one day be able to help disentangle.

The new Writer has taken over a number of methods. For example, std.fmt.formatIntBuf no longer exists. The replacement is the printInt method of Writer. But this requires a Writer instance rather than the simple []u8 previous required.

It's easy to miss, but the Writer.fixed([]u8) Writer function is what you're looking for. You'll use this for any function that was migrating to Writer and used to work on a buffer: []u8.

While migrating, you might run into the following error: no field or member function named 'adaptToNewApi' in '...'. You can see why this happens by looking at the updated implementation of std.fmt.format:

pub fn format(writer: anytype, comptime fmt: []const u8, args: anytype) !void {
    var adapter = writer.adaptToNewApi();
    return adapter.new_interface.print(fmt, args) catch |err| switch (err) {
        error.WriteFailed => return adapter.err.?,
    };
}

Because this functionality was moved to std.Io.Writer, any writer passed into format has to be able to upgrade itself to the new interface. This is done, again only be convention, by having the "old" writer expose an adaptToNewApi method which returns a type that exposes a new_interface: std.Io.Writer field. This is pretty easy to implement using the basic drain implementation, and you can find a handful of examples in the standard library, but it's of little help if you don't control the legacy writer.

I'm hesitant to provide opinion on this change. I don't understand language design. However, while I think this is an improvement over the current API, I keep thinking that adding buffering directly to the Writer isn't ideal.

I believe that most languages deal with buffering via composition. You take a reader/writer and wrap it in a BufferedReader or BufferedWriter. This approach seems both simple to understand and implement while being powerful. It can be applied to things beyond buffering and IO. Zig seems to struggle with this model. Rather than provide a cohesive and generic approach for such problems, one specific feature (buffering) for one specific API (IO) was baked into the standard library. Maybe I'm too dense to understand or maybe future changes will address this more holistically.

联系我们 contact @ memedata.com