Printing, Namespaces, and Hello World
I recently found myself wondering: why does it seem like such a
hassle to print things to stdout? Why no @print
builtin?
What is the difference between the relatively accessible
std.debug.print()
function, and the more cumbersome
std.io.getStdOut().writer().print()
function? Why do it be
like the way it are?
Up to this point, I’ve stubbornly stuck with
std.debug.print
mostly because I’ve had trouble keeping the
writer.print()
pattern in my memory for future use, and
when I have trouble keeping things in mind, it’s usually because I don’t
understand the underlying principles. Time to dig in.
For a more thorough treatise: Loris Cro’s Advanced Hello World
For an excellent review of Zig’s writers, see Karl Seguin’s blog post In Zig, What’s a Writer?
As always, see the standard library docs
STD{IN,OUT,ERR} Are Files
On Linux systems, there are three fundamental abstractions that we
call STDIN
, STDOUT
, and STDERR
.
These are assigned the file handles 0, 1, and 2 respectively. The
std.fs.File
type has a single field, handle
,
which allows us to see this integer assignment for ourselves via the
following:
const std = @import("std");
const File = std.fs.File;
pub fn main() void {
const in: File = std.io.getStdIn();
std.debug.print("stdin handle: {}\n", .{in.handle});
const out: File = std.io.getStdOut();
std.debug.print("stdout handle: {}\n", .{out.handle});
const err: File = std.io.getStdErr();
std.debug.print("stderr handle: {}\n", .{err.handle});
}
which prints:
$ zig run handles.zig
stdin handle: 0
stdout handle: 1
stderr handle: 2
The point of all this is that when printing to the terminal, we are
really writing to a file, in the sense of Linux’ everything-is-a-file
abstraction. To write something to the terminal, we need to go get the
file object that represents the terminal’s output. Most of the time,
that file is STDOUT
.
The Writer Abstraction
From the horse’s mouth: Zig’s std.io.Writer
and
std.io.Reader
provide
standard ways of making use of IO. The std.fs.File
type
follows suit: it has a writer
function which returns a
Writer
type. The function signature is as follows:
pub fn writer(file: File) Writer
If you navigate to the standard library docs’ std.io
page,
you’ll see a whole bunch of different types defined at the top: among
the mix are AnyWriter
, BufferedWriter
,
GenericWriter
, NullWriter
, and at the end,
Writer
. Clicking on Writer
takes us to the
GenericWriter
type function doc page, a minor discordance -
an aliasing - we’re going to avoid for a moment.
On this page, we see ten functions: - any - print - write - writeAll - writeByte - writeByteNTimes - writeBytesNTimes - writeInt - writeStruct - writeStructEndian
Finally, we see print
. Except it looks a bit wild:
pub inline fn print(self: Self, comptime format: []const u8, args: anytype) Error!void
What’s going on?
Printing, Almost, Finally
Let’s see if we can unpack this. First, recall that we’re working
with a Writer
object that was derived from a
File
object. That is what self
refers to: the
‘parent’ object of the print()
function, if you will. Next,
comptime format
: that is the array of bytes that we want to
print, plus formatting, should we choose to include any. Last, is
args
, which are the things that get stuffed into the string
when the print function is called to do its work.
Let’s put this thing to work. A minimal program looks like this:
const std = @import("std");
const Writer = std.io.Writer;
pub fn main() !void {
const out: Writer = std.io.getStdOut().writer();
try out.print("hello writer!", .{});
}
which produces the following output:
hello_writer.zig:64:14: error: expected type 'type', found 'fn (comptime type, comptime type, comptime anytype) type'
var out: Writer = std.io.getStdOut().writer();
...
Damnit. What is it now… Recall the earlier emphasis on how our
Writer
object is derived from a File
object.
Then, note in the above snippet that the Writer
is declared
from std.*io*.Writer
. Turns out, writers defined in the
io
namespace are interfaces, that return a type,
and we need to reference the more constrained
std.fs.File.Writer
in order to properly declare the type of
out
. I picked up this bit of trivia from this
StackOverflow answer. There are four solutions presented in the SO
answer:
- Use
anytype
- Use an alias, like
fs.File.Writer
- Use a “proxy” type, like
fs.File
, on which you’ll callwriter()
- Use full specialization, like
io.Writer(fs.File, fs.File.WriteError, fs.File.write)
NB: I don’t fully understand interfaces and generics; they’re on my to-learn list. For this article, I’m shooting from the hip with those two terms, meaning there’s a good chance I’m not using them correctly. Please see Karl Seguin’s blog openmymind.net for several articles that clarify these things much better than I can!
The first and last options describe how to correctly constrain and
declare the type of const out
for writing to
STDOUT
, when the Writer
type is declared from
the std.io
namespace. The second and third items describe
how to correctly declare the type of const out
when the
Writer
type is declared from the std.fs
namespace.
With that, we’re finally ready to do a print:
const std = @import("std");
const Writer = std.fs.File.Writer;
pub fn main() !void {
const out: Writer = std.io.getStdOut().writer();
try out.print("hello writer!", .{});
}
which produces the following output:
hello writer!
Wonderful! Not bad at all.
Further Down the Rabbithole, But Not all That Far Really, Considering Just How Deep The Rabbithole Appears to Go
All that is making good sense: io.Writer
is Zig’s
interface for writing to files, and we want to use a writer that is
appropriately constrained to the type we’re working with (e.g.,
std.fs.File
). But… is there a way we can just kinda ignore
all that? Well, sort of. There are a few options, each with its own
catch.
Option one is probably the most reasonable option: return to old
habits and use std.debug.print
. The catch here is that it
writes only to STDERR
, which we can see from peeking at its
source code:
pub fn print(comptime fmt: []const u8, args: anytype) void {
lockStdErr();
defer unlockStdErr();
const stderr = io.getStdErr().writer();
nosuspend stderr.print(fmt, args) catch return;
}
We can even import it like so, to avoid the std.debug
part:
const print = @import("std").debug.print;
Option two is to make our own print()
function, with
blackjack, and hoo- I mean, with STDOUT
, and error
handling avoidance. We could try this:
const std = @import("std");
const Writer = std.fs.File.Writer;
pub fn main() void {
print("hello print fn with {} args!\n", .{1});
}
fn print(comptime fmt: []const u8, args: anytype) void {
const stdout: Writer = std.io.getStdOut().writer();
stdout.print(fmt, args) catch return;
}
or even this:
fn print(comptime fmt: []const u8) void {
const stdout: Writer = std.io.getStdOut().writer();
stdout.print(fmt, .{}) catch return;
}
The catch of the first print is that we aren’t able to lock the
target file: there is no equivalent lockStdOut()
function
in std.Progress
. Sure, you could start messing around with
Zig’s mutexes and come up with something fancy, but that is out of my
league. For playing around in single-threaded sandboxes, this is
fine.
The catch of the second one is that we can’t use formatting: we’re
force-feeding an empty args
struct to the real
print call, and constrain ourselves to hardcoded u8 arrays only.
Both of these tricks miss the point, though. The real lesson I’m taking away from all this is that sure, we can faff about, trying to make Zig look more like Python or behave more like C or Go or whatever. We can fuss and kick the dust and rail against design decisions that feel like they break our own safe, well-entrenched mental models of what a ‘good’ decision would have been in this case or that case. This is a pernicious trapping that I attribute to the less well structured self-taught education path. Instead, a better goal is to take Zig as it is, and work to break out of our limiting expectations for what the language should look like, how it should behave.
Closing Thoughts
If you’ve made it this far, thanks for taking the time! This is my inaugural blog post, and I’m looking forward to learning more and writing more.
I cut my teeth with Python, quite late relative to many folk: I was
in my mid-thirties before I even took an interest in computers. I’ve
been using Zig to explore the world of lower level software development,
and while it has been a bit torturous - the docs can feel a bit spartan
in places, for example - it has also been incredibly illuminating. This
discussion of print
semantics is just one of many cases
where I’ve tried to bring my Python habits along for the ride, and where
I’ve railed against a design without thinking at all about why the
decision was made the way it was.
Go forth and stdout.write()
with confidence!