Two phases.
One program.
Every line you write happens twice. Once when the compiler reads it and turns it into a binary, and again, in a different shape, when the CPU actually runs it. The first phase is compile time. The second is runtime. Almost every tradeoff in programming, from type systems to performance to safety, comes down to where the work happens.
What's the difference?
The compiler turns source code into a binary. That phase is called compile time, and it happens once, on a developer's machine, before anything ships.
When someone runs the binary, the OS loads it into memory and the CPU starts executing instructions. That phase is runtime, and it happens every time the program runs.
The same line of source can produce errors in either phase. Which phase it lands in determines who suffers. A compile-time error stays on your laptop. A runtime error reaches a user, possibly at 2 AM, possibly with their data in transit.
Two errors, two phases
// Two errors. Same program structure. Different phases.
fn main() {
// (1) COMPILE-TIME ERROR. Uncomment and the build fails:
//
// let n: u32 = "forty-two";
//
// error[E0308]: mismatched types
// expected `u32`, found `&str`
//
// No binary is produced. The bug never reaches a user.
// (2) RUNTIME ERROR. Compiles fine. Crashes when the program
// actually runs and the value of `i` is finally known.
let arr = [10, 20, 30];
let i = std::env::args().count(); // depends on how it's invoked
println!("{}", arr[i]); // panic if i >= 3
}#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv) {
// (1) COMPILE-TIME ERROR. Uncomment and clang/gcc rejects it:
//
// int n = "forty-two";
//
// error: incompatible pointer to integer conversion
//
// No binary is produced. The bug never reaches a user.
// (2) RUNTIME ERROR. Compiles fine. Reads garbage or crashes,
// depending on what's adjacent in memory at run time.
int arr[] = {10, 20, 30};
int i = atoi(argv[1]); // value is unknown until launch
printf("%d\n", arr[i]); // undefined behaviour if i is OOB
return 0;
}What about an interpreter?
A pure interpreter (Python, Ruby, Bash) skips the up-front compile step. It reads source code and executes it directly. There's still a parse phase that runs before execution, but it happens on the user's machine, every time the program runs. So a typo that a Rust or C compiler catches before shipping shows up in Python only when the line is actually reached at runtime.
This is the same compile/runtime split, drawn at a different point. Less compile-time work means more runtime risk.
What gets decided when?
Some things have to wait for runtime: user input, network responses, file contents, the current time, random numbers. Anything that depends on the world.
But a surprising amount of work can be done at compile time, if the language lets the compiler do it. The trend in modern systems languages is to push more and more decisions earlier, because:
- Compile-time checks catch bugs before users see them. Every type error caught by the compiler is a runtime crash that never happened.
- Compile-time computation is free at runtime. The work already happened on the developer's machine.
- Compile-time-known sizes and types let the compiler pick efficient code. Stack-allocated, inlined, and specialised paths only work when the shape is fixed before the program runs.
| question | compile time | runtime |
|---|---|---|
What's the type of x? | Yes (Rust, C, Java, Go) | Yes (Python, Ruby, JS) |
What's the size of x? | If it's a primitive or fixed array | If it's Vec, String, malloc'd |
Where does x live in memory? | Stack offsets, static addresses | Heap allocations |
Which function does foo() call? | Direct calls, generics | Function pointers, vtables |
What's 10 * 60 * 60? | Constant folded into 36000 | Recomputed every call |
What's read_user_input()? | Can't know | Whatever the user typed |
Computing things at compile time
Most languages now let you tell the compiler "run this for me, please, while you're building." Rust calls these const fn. C++ has constexpr. C has constant expressions and the preprocessor. The result is the same in every case: the value gets baked into the binary, and there's literally nothing for the CPU to compute when the program runs.
// `const fn` lets the compiler run the function at build time.
// The result becomes a baked-in constant, computed once, ever.
const fn factorial(n: u32) -> u64 {
let mut acc: u64 = 1;
let mut i: u32 = 1;
while i <= n {
acc *= i as u64;
i += 1;
}
acc
}
// FACT_10 is computed while the compiler is running on your laptop.
// At runtime, this is just `mov eax, 3628800`. No loop. No work.
const FACT_10: u64 = factorial(10);
fn main() {
println!("baked at compile time: {FACT_10}");
// Same function, called with a value from the world.
// The compiler can't know it, so the loop runs at runtime.
let n: u32 = std::env::args()
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(10);
println!("computed at runtime: {}", factorial(n));
}#include <stdio.h>
#include <stdlib.h>
// C has no general "run this function at compile time" feature, but
// constant expressions and the preprocessor cover the simple cases.
// Most compilers will fold this whole expression into a single number.
enum {
FACT_10 = 1*2*3*4*5*6*7*8*9*10 // 3628800, computed at compile time
};
unsigned long long factorial(unsigned n) {
unsigned long long acc = 1;
for (unsigned i = 1; i <= n; i++) acc *= i;
return acc;
}
int main(int argc, char **argv) {
printf("baked at compile time: %d\n", FACT_10);
unsigned n = argc > 1 ? (unsigned)atoi(argv[1]) : 10;
printf("computed at runtime: %llu\n", factorial(n));
return 0;
}Why this ties to every other page
Look back at every layer this site has covered. Each one has a compile-time / runtime split somewhere, and once you see the pattern it shows up everywhere.
| layer | compile time | runtime |
|---|---|---|
| Number systems | 0xCAFEBABE is parsed and stored as bytes | Bytes are loaded and used; the prefix is gone |
| Binary | Instructions encoded into byte patterns | CPU decodes and executes them |
| ASCII | "hello" baked into rodata | Bytes loaded, sent to a file descriptor, drawn |
| Logic gates | Gate layout fixed at fab time (the chip's compile) | Current flows through that fixed structure |
| CPU | ISA: which bit patterns mean which operations | CPU runs the patterns the compiler chose |
| Memory | Stack offsets, sizes of primitives | Heap allocations, dynamic sizes |
| Operating system | Static linking, syscall numbers fixed in libc | Dynamic linking, page faults, scheduling |
| Variables | "Is the size known?" If yes: stack | "What's the value?" Always runtime |
The whole stack runs on this single distinction. Layer above layer, each one freezes some decision at build time and defers the rest to runtime.
Static vs dynamic dispatch
The classic example of a tradeoff that lives exactly on this line: how does the program decide which function to call?
- Static dispatch picks the function at compile time. The call site jumps directly to the right address. Fast, inlinable, but the binary contains a copy of the function for every concrete type that uses it.
- Dynamic dispatch picks the function at runtime. The call site reads a pointer (a vtable entry, a function pointer) and jumps through it. Slightly slower per call, but the binary stays small and the same code handles any type that follows the contract.
Same problem, two timings, different tradeoffs.
// STATIC dispatch (compile time): the compiler stamps out one
// version of `area` for every concrete shape that calls it.
// Each call is a direct jump to a known function.
trait Shape { fn area(&self) -> f64; }
struct Circle { r: f64 }
struct Square { side: f64 }
impl Shape for Circle { fn area(&self) -> f64 { 3.14159 * self.r * self.r } }
impl Shape for Square { fn area(&self) -> f64 { self.side * self.side } }
fn area_static<S: Shape>(s: &S) -> f64 { s.area() } // resolved at build
// DYNAMIC dispatch (runtime): one copy of `area_dyn`. At runtime,
// each call follows a vtable pointer to figure out which method to run.
fn area_dyn(s: &dyn Shape) -> f64 { s.area() } // resolved at run
fn main() {
let c = Circle { r: 1.0 };
let s = Square { side: 1.0 };
// The compiler emits two area_static specialisations.
// Each call below is as fast as a direct function call.
println!("{}", area_static(&c));
println!("{}", area_static(&s));
// One area_dyn function, two indirect calls. A few cycles slower
// per call, but only one binary copy of the code.
let shapes: Vec<&dyn Shape> = vec![&c, &s];
for s in &shapes {
println!("{}", area_dyn(*s));
}
}// STATIC dispatch: direct function calls, resolved by the linker.
#include <stdio.h>
double circle_area(double r) { return 3.14159 * r * r; }
double square_area(double side) { return side * side; }
// DYNAMIC dispatch: function pointers. The address of the function
// to call is decided at runtime, by reading the pointer.
typedef double (*area_fn)(void *self);
typedef struct { double r; } Circle;
typedef struct { double side; } Square;
double circle_area_fn(void *self) { return circle_area(((Circle*)self)->r); }
double square_area_fn(void *self) { return square_area(((Square*)self)->side); }
typedef struct { void *self; area_fn area; } Shape;
int main(void) {
Circle c = { 1.0 };
Square s = { 1.0 };
// Static: the linker hard-wires the call. No indirection.
printf("%f\n", circle_area(c.r));
printf("%f\n", square_area(s.side));
// Dynamic: the call goes through a pointer. Cheap, but the
// CPU's branch predictor has to guess the target every call.
Shape shapes[] = {
{ &c, circle_area_fn },
{ &s, square_area_fn },
};
for (int i = 0; i < 2; i++)
printf("%f\n", shapes[i].area(shapes[i].self));
return 0;
}Three families of execution model
Faster languages do more work at compile time, in exchange for less flexibility at runtime. More dynamic languages defer almost everything to runtime, in exchange for the ability to redefine code, change types, or load new modules on the fly. There's no winner; the right answer depends on what you're building.
What pushing decisions earlier actually buys
- Safety. Bugs caught at compile time never reach a user. Rust's borrow checker is the extreme version of this idea.
- Speed. Work done once at build time costs nothing at run time. Constant folding, inlining, generics specialisation.
- Predictability. Static layouts and static dispatch eliminate whole classes of "it depends on the data" surprises.
- Smaller attack surface. No
eval, no dynamic loading, no surprises means fewer ways for an attacker to inject behaviour the compiler didn't see.
The whole site, framed by time
- Build time: a chip designer writes Verilog. The fab "compiles" it into transistor masks. The masks become silicon.
- Build time: a compiler reads your source. It decides types, sizes, addresses, instructions, and emits a binary.
- Build time: a linker stitches your binary together with libc, baking in syscall numbers and resolving symbol addresses.
- Run time: the OS loads the binary into a fresh virtual address space.
- Run time: the CPU starts executing instructions. Every fetch-decode-execute cycle is one step in the loop the compiler set up.
- Run time: page faults map virtual pages to physical RAM. The allocator hands out heap regions on demand.
- Run time: the program reads input, makes decisions, writes output. The world enters the picture for the first time.
Where to dig in next
The compile-time / runtime split shows up under a hundred different names. A few rabbit holes worth following:
- Type systems, especially Hindley-Milner and dependent types, push more invariants into compile time.
- Macros and metaprogramming let user code run at compile time. Rust macros, C++ templates, Lisp macros are three very different takes.
- JIT compilation, especially the V8 and HotSpot designs, blends the two phases by recompiling hot code while the program runs.
- Partial evaluation is the formal study of moving computation between phases. The "Futamura projections" are the classic result.
- Profile-guided optimisation goes the other direction: feed runtime data back into the compiler for the next build.
Every one of those is a different way to shift work along the same line.