Readers and Writers
const std = @import("std");
const expect = std.testing.expect;
test "iterate lines with takeDelimiter" {
var reader: std.Io.Reader = .fixed("line one\nline two\n");
// `takeDelimiter` consumes the delimiter and returns null at the end,
// which is what you want for a line loop.
var count: usize = 0;
while (try reader.takeDelimiter('\n')) |line| {
count += 1;
try expect(line.len == 8);
}
try expect(count == 2);
}
test "the Exclusive variant leaves the delimiter behind" {
var reader: std.Io.Reader = .fixed("line one\nline two\n");
const first = try reader.takeDelimiterExclusive('\n');
try expect(std.mem.eql(u8, first, "line one"));
// The '\n' has NOT been consumed, so the next call sees it immediately
// and returns an empty slice. Use `takeDelimiter` to loop over lines,
// or `toss(1)` to step past the delimiter yourself.
const second = try reader.takeDelimiterExclusive('\n');
try expect(second.len == 0);
reader.toss(1);
const third = try reader.takeDelimiterExclusive('\n');
try expect(std.mem.eql(u8, third, "line two"));
}
test "take a fixed number of bytes" {
var reader: std.Io.Reader = .fixed("abcdef");
const chunk = try reader.take(3);
try expect(std.mem.eql(u8, chunk, "abc"));
}
test "write into a caller-owned buffer" {
var buf: [32]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try writer.writeAll("hello");
try writer.print(" {d}", .{42});
try expect(std.mem.eql(u8, writer.buffered(), "hello 42"));
}
test "allocating writer grows as needed" {
const gpa = std.testing.allocator;
var writer: std.Io.Writer.Allocating = .init(gpa);
defer writer.deinit();
for (0..5) |i| try writer.writer.print("{d},", .{i});
try expect(std.mem.eql(u8, writer.written(), "0,1,2,3,4,"));
}
test "a fixed writer reports when it runs out" {
var buf: [4]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try std.testing.expectError(error.WriteFailed, writer.writeAll("too long"));
}Zig 0.15 replaced the old generic Reader/Writer with non-generic
std.Io.Reader and std.Io.Writer. Two things changed that matter every day:
- The buffer belongs to the interface, not the implementation. You supply it, so there is no hidden allocation and you control the size.
- Writers are buffered, so you must
flush. Forgetting is the single most common mistake with the new API.
“Non-generic” is the word doing the work in that first sentence. The old
Writer was a type parameter. Every function taking one was therefore generic
and got compiled again per writer type, and a struct that wanted to store one
had to be generic too. std.Io.Writer is a concrete type holding a pointer
and a vtable, so a function takes *std.Io.Writer and that is the end of it.
Compile times drop, error messages get shorter, and writers become values you
can put in a field.
Constructing them
var reader: std.Io.Reader = .fixed("some bytes");
var writer: std.Io.Writer = .fixed(&buf);
var growable: std.Io.Writer.Allocating = .init(gpa);
For files, file.writerStreaming(io, &buf) gives you a File.Writer whose
.interface field is the std.Io.Writer to use.
That .interface field is the pattern to recognise. The concrete type holds
the state that a file writer needs, and embeds the generic interface as a
field, so you pass &file_writer.interface to anything that takes a writer.
Getting a compile error about File.Writer where a *std.Io.Writer was
expected almost always means a missing .interface.
Flush, and where it belongs
var buf: [4096]u8 = undefined;
var fw = std.Io.File.stdout().writerStreaming(io, &buf);
defer fw.interface.flush() catch {};
try fw.interface.print("{d}\n", .{42});
Nothing reaches the file until the buffer fills or you flush. A program that
prints and then exits without flushing prints nothing, which is a confusing
first encounter and the reason the defer goes directly under the
construction.
Note the buffer size is yours to pick, and it is a real decision. One byte means a syscall per write. Four kilobytes is a page and a sensible default. Larger helps for bulk output and does nothing for a program that prints six lines.
Reading lines: mind the variant
This one catches people, including an earlier draft of this page:
| Method | Consumes delimiter | At end of stream |
|---|---|---|
takeDelimiter | yes | returns null |
takeDelimiterExclusive | no | error |
takeDelimiterInclusive | yes, and includes it | error |
So takeDelimiterExclusive called twice on "a\nb\n" returns "a" and then
an empty slice, because the \n is still there. For a line loop you want:
while (try reader.takeDelimiter('\n')) |line| { ... }
Use toss(n) to skip bytes manually if you do use the exclusive form.
The slice a take returns points into the reader’s buffer and is valid until
the next read. Keeping a line means copying it. That is the same borrowed-view
rule as ArrayList.items, and it is what makes reading a large file with a
small buffer possible at all.
Running out of room
A .fixed writer fails with error.WriteFailed when the buffer is full. It
does not truncate silently.
On the reading side, a line longer than the reader’s buffer is
error.StreamTooLong rather than a partial line. Same reason: the interface
will not hand you something that looks complete and is not. If input line
length is unbounded and untrusted, that error is your length limit, and
treating it as a protocol violation is usually correct.
Why one interface
Because everything becomes composable. A parser written against
*std.Io.Reader is tested against .fixed("literal input") and deployed
against a socket without changing. A formatter written against
*std.Io.Writer writes to a file, a buffer, or a hash. That is the reason the
networking chapters can run in a browser: the protocol
code takes bytes and returns bytes, and only the chapters genuinely about
sockets need one.