When the Checks Are Off
The last chapter ended on an awkward admission: the program you ran said its safety checks were absent. This chapter is why, and it is the idea that separates systems programming from everything above it.
A high-level language answers every question. Read past the end of a list and
Python raises IndexError. Always, on every machine, in every configuration.
The answer costs something to guarantee, and you pay it whether you needed it
or not.
Down here some questions are deliberately left unanswered. Not “answered unpredictably”: unanswered. The specification says that if your program does this thing, the language makes no promise about what happens next. That is undefined behaviour, and it is easy to misread as a fancy way of saying “crash” or “garbage value”. It is neither.
The right way to read it is as a promise you made. When the language leaves integer overflow undefined, it is telling the compiler: assume it never happens. The compiler then uses that assumption, because that is what makes the generated code fast. It might delete a check you wrote, because your check could only be true in a situation you promised would not arise. This is why undefined behaviour is worse than a wrong answer. A wrong answer is local. A broken promise can change code somewhere else.
Zig’s position is the interesting one: the checks exist, and you choose per build whether to pay for them.
The program
const std = @import("std");
const builtin = @import("builtin");
pub fn main(init: std.process.Init) !void {
var buf: [1024]u8 = undefined;
var stdout_writer = std.Io.File.stdout().writerStreaming(init.io, &buf);
const out = &stdout_writer.interface;
// Every program can see how it was built.
try out.print("build mode: {t}\n", .{builtin.mode});
try out.print("safety checks present: {}\n\n", .{builtin.mode.runtimeSafety()});
var a: u8 = 200;
var b: u8 = 100;
_ = &a;
_ = &b;
// Three ways of saying what should happen when 300 does not fit in a u8.
// None of them change with the build mode, because none of them are
// relying on a check that a release build is allowed to remove.
// 1. Wrap, deliberately.
try out.print("a +% b = {d}\n", .{a +% b});
// 2. Refuse, as an error the caller has to handle.
if (std.math.add(u8, a, b)) |sum| {
try out.print("std.math.add = {d}\n", .{sum});
} else |err| {
try out.print("std.math.add = {t}\n", .{err});
}
// 3. Answer and report, with no error handling at all.
const result = @addWithOverflow(a, b);
try out.print("@addWithOverflow = {d}, overflowed: {d}\n", .{ result[0], result[1] });
// 4. Saturate at the limit.
try out.print("a +| b = {d}\n", .{a +| b});
try out.flush();
}What just happened
The program read its own build mode. builtin.mode is available to any Zig
program, and builtin.mode.runtimeSafety() is the direct question: are the
checks in this build. Printing them is a habit worth having, because almost
every “why does it behave differently on the server” story ends here.
The four modes, and what each is promising:
- Debug: every safety check present, no optimisation. This is the default
for
zig runandzig build, so it is what you get the entire time you are working. Overflow, out-of-bounds, invalid casts and the rest all stop the program with a message and a line number. - ReleaseSafe: optimised, and the safety checks stay. This is the mode most server software should ship in. The checks are not free, but they are cheap, and a program that stops with a clear message beats one that continues with corrupt state.
- ReleaseFast: optimised, checks removed. You are promising that none of the checked situations occur.
- ReleaseSmall: optimised for size, checks removed. Same promise.
The snippets on this site are built ReleaseSmall, because they are downloaded
by every reader, which is why the output above says small and false.
The four operators did not care. +%, std.math.add, @addWithOverflow
and +| behave identically in all four modes, because each of them states in
the source what should happen when the value does not fit. They are not relying
on a check that a release build is entitled to remove. When it matters, say
what you mean and the build mode stops being part of the answer.
The one that does depend on the build
This is the same program as the last four lines above, using plain +. It is
the one snippet on this page built ReleaseSafe rather than ReleaseSmall, so the
check is still in it. Press Run.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
_ = init;
var a: u8 = 200;
var b: u8 = 100;
_ = &a;
_ = &b;
// 300 does not fit in a u8. In this build the check runs and stops here.
// In ReleaseFast or ReleaseSmall there is no check, and the value you
// would get is not defined. Not "wraps". Undefined.
const sum = a + b;
std.debug.print("sum: {d}\n", .{sum});
}panic: integer overflow
Now read that back against the four operators that did not care. Each of them answered the question in the source, so each behaves the same in all four modes. This one asked the build mode to answer it, and got a different answer depending on which build you are in:
| Build | a + b where the sum does not fit |
|---|---|
| Debug | stops, with the message above |
| ReleaseSafe | stops, with the message above |
| ReleaseFast | undefined |
| ReleaseSmall | undefined |
Note what the last two rows do not say. Not “wraps”. It might wrap on your
machine today, and writing code that depends on that is exactly the broken
promise described at the top of this page. +% is right there and costs
nothing.
There is a reason this page can show you the panic and cannot show you the other half. In a safety build the behaviour is defined, so our build runs this program on every change and requires it to stop with that exact message. In a release build there is no defined answer, so there would be nothing correct to check it against. A guide that printed whatever its compiler happened to produce that day, and called it the result, would be teaching you to rely on the thing this chapter exists to warn you about.
Check yourself
If ReleaseSafe keeps the checks and is optimised, why would anyone ship ReleaseFast?
Because the checks are not always cheap in the place that matters. A bounds
check inside a loop running a billion times per frame is measurable, and there
are programs where that is the whole point. The judgement is per program and
sometimes per function, which is why Zig also lets you turn safety off for one
block with @setRuntimeSafety(false) rather than making it all-or-nothing for
the binary. Start in ReleaseSafe and move only where you have measured.
If you have written C
Undefined behaviour is the same concept, and C has a great deal more of it:
signed overflow, reading an uninitialised variable, dereferencing null, most
pointer arithmetic that leaves an object, strict-aliasing violations, and about
two hundred more. Almost none of it is checked by default, which is why the
sanitiser tooling exists and why -fsanitize=undefined finds things in code
that has worked for a decade.
Zig differs in three places. The default build mode has the
checks on, so you meet the mistake the first time you run the code rather than
after adding a flag you have to know about. Where C leaves a common operation
undefined, Zig usually provides a spelling for each intent instead, which is
what the four operators above are. And an uninitialised variable has to be
written = undefined, so reading one is a thing you can grep for.
The Zig-specific list, what is checked and what each check catches, is in Runtime Safety, and the integer rules in detail are in Integer Rules.
Next: Strings Are Bytes, where the bytes from chapter one turn into text.