Writergate: Zig I/Oインターフェースの大幅刷新
原題: Writergate: Zig I/O Interface Overhaul
日本語訳
# タイトル
Writergate: Zig I/O インターフェースの刷新
# 本文
Writergateは、2023年後半に始まり、2025年8月に`GenericWriter`、`GenericReader`、`AnyWriter`、`AnyReader`の完全な削除をもって完了した、ZigのI/Oインターフェース刷新の通称です。最近ZigのI/Oコードに触れたことがあれば、その影響を感じているはずです。
### 変更点
旧APIは、型パラメータを持つジェネリック型を使用していました:
```zig
// 旧(削除済み)
const stdout = std.io.getStdOut();
const writer = stdout.writer();
try writer.print("Hello {s}\n", .{"world"});
```
新しいAPIは、vtable(仮想関数テーブル)を持つ具象型と、明示的なバッファリングを使用します:
```zig
// 新(0.15以降)
const stdout = std.fs.File.stdout();
var buffer: [4096]u8 = undefined;
var file_writer = stdout.writer(&buffer);
const writer = &file_writer.interface;
defer writer.flush() catch {};
try writer.print("Hello {s}\n", .{"world"});
```
破壊的変更:
- **名前空間**: `std.io` は `std.Io` に変更されました。
- **バッファリング**: 実装側ではなく、呼び出し側がバッファを提供します。
- **型**: Writer/Readerはジェネリックではなく、vtableを持つ具象型です。
- **Flush**: 明示的にflushする必要があります。そうしないと、出力が表示されない可能性があります。
### なぜ重要なのか
旧来のジェネリックな設計は、APIを「汚染」していました。Writerを受け取るあらゆる関数がジェネリックになり、その結果、それらを含むすべての構造体もジェネリックにならざるを得ませんでした。Andrew Kelley氏のWritergateに関するPRでは、旧インターフェースは「それらを含む構造体を汚染する」と表現されています。私は、一つの`anytype`パラメータが蔓延し、ライブラリの半分がジェネリックになってしまうというパターンを、コードベース全体で見てきました。これにより、APIの再利用性が制限され、コンパイル時間にも悪影響を及ぼしていました。
Zig 0.16における後続の変更では、I/Oをメモリ割り当てと同様に扱います。コードは`Allocator`に依存するのと同様に、`Io`インスタンスに依存します。これにより、以下が可能になります:
- **非同期処理 (Async)**: 0.16の`Io` vtableには、`async`、`await`、`cancel`のプリミティブが含まれています。現在のスレッドプールでも、将来的に`io_uring`や`kqueue`といったバックエンドが成熟すれば、同じコードで動作します。
- **パフォーマンス**: バッファがvtableの上位に位置するため、バッファリングされた書き込みはホットパスにおいて仮想ディスパッチを発生させません。
- **正確なエラー**: いたるところで`anyerror`を使う代わりに、バックエンドの操作は特定の「エラーセット」を持ちます。Writer/Readerインターフェースは簡潔な`WriteFailed`/`ReadFailed`を公開し、詳細は具象実装側に保持されます。
### vtable アーキテクチャ
新しいシステムには3つのレイヤーがあります:
`Io` (バックエンド) $\leftarrow$ Threaded, Evented, Uring... (0.16)
$\downarrow$
`Io.Writer` / `Io.Reader` $\leftarrow$ drain, stream, flush, rebase
$\downarrow$
`File.Writer` / `File.Reader` $\leftarrow$ 具象実装
カスタムライターはインターフェースを埋め込み、`@fieldParentPtr`を介して親構造体を復元します:
```zig
pub const MyWriter = struct {
my_data: u32,
interface: std.Io.Writer,
fn drain(io_w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize {
const self: *MyWriter = @alignCast(@fieldParentPtr("interface", io_w));
_ = self.my_data; // 親構造体のフィールドにアクセス可能
// バッファリングされたデータと入力データを処理し、消費されたバイト数を返す。
// 最後のスライスを除き、各スライスは一度だけカウントされる。
// 最後のスライスはsplat回繰り返される。
io_w.end = 0;
var total: usize = 0;
for (data[0 .. data.len - 1]) |slice| total += slice.len;
total += data[data.len - 1].len * splat;
return total;
}
};
```
### よくある落とし穴
私はこれらすべてを少なくとも一度は経験しています:
- **flushの忘れ**: プログラム終了時にバッファに残っているバイトは、静かに失われます。短いプログラムを実行しても、何も出力されず、正常終了してしまうのです。これは非常に厄介です。
- **フォーマット指定子**: `format`メソッドを持つ型には、`"{}"`ではなく`"{f}"`を使用してください。
- **標準ストリーム**: `std.io.getStdOut()`は、現在`std.fs.File.stdout()`になっています。
- **インターフェースのコピー**: 親の実装に埋め込まれたインターフェースをコピーしないでください(`var w = impl.interface`)。常にポインタを使用してください(`&impl.interface`)。vtableは`@fieldParentPtr`を使用して親を復元するため、コピーしてしまうとその仕組みが壊れてしまいます。`Writer.fixed`のようなスタンドアロンのライターはプレーンな値であるため、コピーしても問題ありません。詳細は移行ガイドを参照してください。
原文(英語)を表示
Writergate is the informal name for Zig’s I/O interface overhaul that began in late 2023 and culminated in August 2025 with the complete removal of GenericWriter
, GenericReader
, AnyWriter
, and AnyReader
. If you’ve touched Zig I/O code recently, you’ve felt the impact.
What changed
The old API used generic types with type parameters:
// Old (removed)
const stdout = std.io.getStdOut();
const writer = stdout.writer();
try writer.print("Hello {s}\n", .{"world"});
The new API uses concrete types with vtables and explicit buffering:
// New (0.15+)
const stdout = std.fs.File.stdout();
var buffer: [4096]u8 = undefined;
var file_writer = stdout.writer(&buffer);
const writer = &file_writer.interface;
defer writer.flush() catch {};
try writer.print("Hello {s}\n", .{"world"});
The breaking changes:
- Namespace:
std.io
becamestd.Io
- Buffering: Caller provides the buffer, not the implementation
- Types: Writer/Reader are concrete types with vtables, not generics
- Flush: You must flush explicitly; output may not appear without it
Why it matters
The old generic design poisoned APIs: any function accepting a writer became generic, which forced all containing structs to become generic. Andrew Kelley’s Writergate PR describes the old interface as “poisoning structs that contain them”. I’ve seen this pattern infect entire codebases: one anytype
parameter spreads until half your library is generic. It limited API reusability and hurt compile times.
The follow-up in Zig 0.16 treats I/O like memory allocation: code depends on an Io
instance the same way it depends on an Allocator
. This enables:
- Async: The 0.16
Io
vtable includesasync
,await
, andcancel
primitives. Same code works with thread pools today, io_uring or kqueue as those backends mature. - Performance: Buffer sits above the vtable, so buffered writes don’t hit virtual dispatch in hot paths.
- Precise errors: Instead of
anyerror
everywhere, backend operations carry specific error sets; the Writer/Reader interfaces expose a compactWriteFailed
/ReadFailed
, with details kept on the concrete implementation.
The vtable architecture
The new system has three levels:
Io (Backend) ← Threaded, Evented, Uring... (0.16)
↓
Io.Writer / Io.Reader ← drain, stream, flush, rebase
↓
File.Writer / File.Reader ← Concrete implementations
Custom writers embed the interface and recover the parent via @fieldParentPtr
:
pub const MyWriter = struct {
my_data: u32,
interface: std.Io.Writer,
fn drain(io_w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize {
const self: *MyWriter = @alignCast(@fieldParentPtr("interface", io_w));
_ = self.my_data; // Can access parent struct fields
// Process buffered + incoming data, return bytes consumed.
// Every slice counts once, except the last: it repeats splat times.
io_w.end = 0;
var total: usize = 0;
for (data[0 .. data.len - 1]) |slice| total += slice.len;
total += data[data.len - 1].len * splat;
return total;
}
};
Common pitfalls
I’ve hit all of these at least once:
- Forgetting flush: Bytes still sitting in the buffer at exit are silently lost. A short program runs, prints nothing, exits successfully. Maddening.
- Format specifier: Use
"{f}"
for types withformat
methods, not"{}"
- Standard streams:
std.io.getStdOut()
is nowstd.fs.File.stdout()
- Copying interfaces: Never copy an interface embedded in a parent implementation (
var w = impl.interface
); always use pointers (&impl.interface
). The vtable recovers the parent with@fieldParentPtr
, and the copy breaks that. Standalone writers likeWriter.fixed
are plain values and copy fine. See the migration guide for details.