Compile-time Programming
The theme of the phase continues. First you made failure explicit; then you made one procedure work for many types; now you make whole decisions belong to the build rather than to the run. A branch that the compiler resolves cannot be slow, cannot be wrong at runtime, and cannot be forgotten.
when, Revisited
You met when in the control-flow lesson as "the if that the compiler decides". Here is what that is worth.
The Branch That Never Exists
Plain if picks a branch while the program runs; when picks one while it is being compiled, and the loser is not merely skipped — it is never built:
// Only one of these blocks reaches the executable at all.
when ODIN_OS == .Windows {
fmt.println("this build targets Windows")
} else when ODIN_OS == .Linux {
fmt.println("this build targets Linux")
} else {
fmt.println("some other platform")
}
Two consequences follow, and the second one is the important one. The first: no runtime test, so the cost is zero. The second: code for another platform need not even be valid where you are building — a Windows-only procedure call inside a when ODIN_OS == .Windows block causes no trouble on Linux, because the compiler never looks inside it.
That is why when, not if, is the tool for platform differences. An if would have to compile both sides.
when as an Expression
when also works inside an expression, in the form value when condition else value. The standard library uses it to pick a constant that depends on the machine:
// The shape used by the standard library itself.
INT_MAX :: 9223372036854775807 when size_of(int) == 8 else 2147483647
The value is fixed before your program starts, so reading it later costs nothing at all — there is no comparison left to perform.
when instead of if. Nothing is lost and a branch disappears.
What the Compiler Already Knows
Odin defines a family of constants that describe the build itself. They need no import, they are fixed while compiling, and each one answers a question you would otherwise have to hard-code — or discover at three in the morning on someone else's machine.
The ODIN_* Constants
The file that declares them documents each one, so let us quote it rather than paraphrase. Four of its descriptions, verbatim:
ODIN_ARCH
"An enum value indicating the target's CPU architecture.
Possible values are .amd64, .i386, .arm32, .arm64, .wasm32,
.wasm64p32, and .riscv64."
ODIN_DEBUG
"true if the -debug command line switch is passed, which enables
debug info generation."
ODIN_ENDIAN
"An enum value indicating the endianness of the target.
Possible values are .Little and .Big."
ODIN_NO_BOUNDS_CHECK
"true if the -no-bounds-check command line switch is passed, which
disables bounds checking at runtime."
Read the last one twice, because it ties two earlier lessons together. The bounds checking you met in the collections lesson can be switched off for an entire build with one command-line flag — and your own code can ask whether that happened. The flag changes the build; the constant lets the code know.
Three more deserve to be in your hands immediately, since they answer questions you will otherwise guess at:
// Which system and which processor is this build for?
when ODIN_OS == .Windows { /* ... */ }
when ODIN_ARCH == .arm64 { /* ... */ }
// Is this a debug build, or a release one?
when ODIN_DEBUG {
fmt.println("verbose diagnostics enabled")
}
// Which byte order does the target use? This matters for binary formats.
when ODIN_ENDIAN == .Big { /* ... */ }
There are around twenty of these constants in total — the build mode, the project name, the compile timestamp, the micro-architecture, the Windows subsystem — and they are all listed and described in the standard library's own source file. When you need one, read it there rather than guessing a name.
size_of, align_of, offset_of
The other half of what the compiler knows concerns your own types. Three built-ins answer questions about layout, and all three are compile-time constants:
Header :: struct {
tag: u8,
size: u32,
}
fmt.println(size_of(Header)) // 8 — four bytes of data, four of padding
fmt.println(align_of(Header)) // 4 — the boundary it must start on
fmt.println(offset_of(Header, size)) // 4 — where the second field begins
You have met these before, in the types, structs and data-oriented lessons. What this lesson adds is that they can be asserted, turning an assumption into something the build enforces:
#assert(size_of(Particle) == 24) // the layout from the DOD lesson
#assert(offset_of(Header, size) == 4) // the padding is where I think it is
An assumption written that way is a promise with a witness. If a later edit adds a field, the build stops and tells you — instead of a benchmark quietly getting slower, or a file format quietly becoming unreadable.
Directives: Talking to the Build
Some decisions do not belong inside a procedure at all — they belong to a whole file. Odin handles those with directives: lines beginning with #+ at the top of a file, read before anything else in it.
Build Tags and Platform Files
The standard library organises platform code by filename, and the pattern is unmistakable once you look at one package's file list:
dir.odin dir_linux.odin dir_posix.odin dir_windows.odin
pipe.odin pipe_linux.odin pipe_posix.odin pipe_windows.odin
path.odin path_linux.odin path_posix.odin path_windows.odin
One package, several files, and the suffix tells the compiler which file belongs to which system. Building on Linux takes dir_linux.odin together with the shared dir.odin; on Windows it takes dir_windows.odin instead. Nobody writes one file sprinkled with platform tests, because the platform is the file.
For cases a filename cannot express — "this file is for Linux and macOS but not Windows", or "only when a certain feature is enabled" — a file can carry an explicit build tag: a #+build line at the top, listing what it belongs to. You will meet those lines at the head of library files, and they do the same job as the filename suffix, said out loud.
The Rest of the #+ Family
Four more file-level directives, each quoted from a file in the standard library:
#+private // core:os — everything in this file is private
#+vet !using-stmt !using-param // the demo file — which vet checks to relax
#+feature dynamic-literals // the demo file — opt into a newer feature
#+no-instrumentation // the runtime — do not instrument this file
Two of them deserve a sentence each. #+private hides everything in the file from other packages — the tool you want for a helper that is nobody else's business, and the next lesson looks at visibility properly. And #+feature is how a newer language feature is opted into per file, which explains the occasional line of it you will see at the top of otherwise ordinary code.
The pattern behind all of them is the same as the ODIN_* constants: instead of discovering something at runtime, you state it to the compiler and let it act on the statement.
intrinsics — The Compiler's Toolbox
base:intrinsics holds the operations only a compiler can perform: asking about types, counting bits, loading memory without alignment assumptions, reading the processor's cycle counter. You met several of its names in the generics lesson; here is the rest.
Bit Intrinsics
The bit-level operations are collected in core:math/bits, which binds each of these names to the intrinsic that implements it:
// From core:math/bits:
// count_ones how many bits are set
// count_zeros how many are not
// count_trailing_zeros position of the lowest set bit
// count_leading_zeros position of the highest set bit
// reverse_bits flip the order of the bits
// byte_swap swap the byte order — an endianness conversion
// overflowing_add/sub/mul arithmetic that reports overflow instead of trapping
Each of these is a single processor instruction that no hand-written loop can match — counting set bits, locating the highest one, reversing a word. Reaching for the intrinsic instead of writing a loop is not micro-optimisation; it is using the tool the hardware provides.
Type Intrinsics
The questions about types come from the same package, and you have already used them as constraints:
// The type questions from the generics lesson:
// type_is_integer, type_is_unsigned, type_is_numeric,
// type_is_comparable, type_is_enum, type_is_enumerated_array
// type_elem_type, type_bit_set_elem_type
//
// And introspection on values:
// typeid_of(x) the type id of a value
// type_of(x) its type
// type_info_of(T) the compiler's own description of a type
All of them are answered while compiling, which is exactly why they may appear in a constraint, in a result type, or inside a when block — places where a runtime value would simply be too late.
#caller_location — Knowing Where You Are
One more built-in deserves its own section, because it appears in real signatures as a default parameter. It carries the location of the call: file, line and procedure.
// Quoted from core:slice: a parameter that defaults to the caller's own
// location, so callers never pass it.
bitset_to_enum_slice_with_make :: proc(bs: $T, $E: typeid,
allocator := context.allocator,
loc := #caller_location) -> (slice: []E)
That pair of default parameters is an idiom you will see throughout the standard library: an allocator defaulting to the context's, and a location defaulting to the caller's. The practical effect is that an allocation which fails can report where in your code it was requested — without you passing anything at all.
You can use it in the same way. Give any procedure of your own a default loc := #caller_location and it acquires the ability to say where it was called from: useful in a logging helper, an assertion wrapper, or your own error reporter.
#assert as Living Documentation
The last idea of this lesson is the one to carry forward. You have already seen #assert guard the size of the error type in core:os; here it is doing the same job for assumptions from earlier lessons:
// A comment can rot; an assertion cannot.
#assert(size_of(Error) == size_of(u64)) // the error type stays one word wide
#assert(size_of(Particle) == 24) // the DOD layout is intact
#assert(offset_of(Header, size) == 4) // the padding has not moved
Every one of those could have been a sentence in a comment — and every one would have become quietly false the first time somebody added a field. As assertions, they are statements the build refuses to accept once they stop being true.
That is the same instinct as assert in the errors lesson, aimed at the facts that exist before a program runs: write the assumption down where a machine can check it, so that the earliest possible moment is also the moment you find out.
Where This Goes Next
The last lesson of Phase 5 is about where code lives: how Odin arranges files into packages, how packages are found when you import them, and what "public" and "private" mean in a language with no access keywords.
The Page in One Breath
whenis decided while compiling, so the losing branch is never built — and code for another platform need not even be valid where you are building.value when condition else valuepicks a constant at compile time, leaving nothing to evaluate at runtime.- The
ODIN_*constants describe the build:ODIN_OS,ODIN_ARCH,ODIN_DEBUG,ODIN_ENDIAN,ODIN_NO_BOUNDS_CHECK, and about twenty more. size_of,align_ofandoffset_ofanswer layout questions, and#assertturns those answers into checks.- Platform code lives in files named for the platform —
_linux,_windows,_posix— with explicit build tags for combinations a filename cannot express. - The
#+directives configure a whole file:#+private,#+vet,#+feature,#+no-instrumentation. base:intrinsicsprovides the bit operations, type questions and introspection — all answered at compile time.#caller_locationas a default parameter lets a procedure know where it was called from, at no cost to its callers.
when, put #assert(size_of(int) == 8) beside it, and then change the number to 4 and read what the compiler says. That message is the compiler doing your thinking for you — which is the whole idea of Phase 5.
Continue with Packages & Collections →