Compile-time Programming

Odin's compiler is not a translator that keeps its opinions to itself: it answers questions. How wide is this type? Which system am I building for? Is this structure laid out the way I assumed? This lesson is about asking those questions and letting the answers change what gets built.

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.

Get into the habit early. When you write a condition whose answer is already known to the compiler — a size, a platform, a build mode — ask whether it belongs in 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.

Source code entering the compiler, which resolves branches, type widths and layout assumptions, producing an executable that contains one branch, real numbers and no runtime checks
What compile-time programming removes. Every question answered while building is a test, a branch or a whole copy of code that the executable never has to carry.

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

  • when is 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 value picks 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_of and offset_of answer layout questions, and #assert turns 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:intrinsics provides the bit operations, type questions and introspection — all answered at compile time.
  • #caller_location as a default parameter lets a procedure know where it was called from, at no cost to its callers.
Well done. A good exercise: write a procedure that prints one message on Windows and another on Linux using 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 →