Compile-Time Evaluation

`constexpr`, `consteval`, `constinit`, Rust's `const fn` and Zig's `comptime` are all one idea: the compiler contains an interpreter for its own language, and work moved into it disappears from the running program and reappears in the build.

The question

How much of my program can the compiler just run before it ships, and what do I pay for that?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

A second program, executed inside the compiler during semantic analysis. The constant evaluator interprets the typed AST against its own abstract machine: a heap of objects with tracked lifetimes and initialisation state, a call stack with a depth limit, and a step counter that aborts runaway evaluation. Its output is not machine code and not IR — it is a *value in the type system*, which can then be a template argument, an array bound, a case label, or a blob written into the constants section of the object file. The question this representation exists to answer is: does this expression have a value that is knowable now, and if so, exactly which one?

What this phase may assume or do

An expression may be evaluated at compile time only if every operation on the path actually taken lies inside the language's constant-expression subset: no undefined behavior anywhere in the evaluation, no read of an object whose lifetime began outside it unless that object is itself constant, no I/O, no reinterpretation of storage through a cast, and no allocation that outlives the evaluation. When the condition fails the rule is not "do it anyway" — the compiler must either fall back to run-time evaluation, or reject the program, and *which* of those two happens is precisely what distinguishes the keywords. constexpr permits the fallback; consteval forbids it; a constexpr variable's initializer must succeed or the program is ill-formed.

Key points

  • The compiler contains an interpreter for its own language; compile-time evaluation is that interpreter running your code during semantic analysis.
  • constexpr is a permission, not a promise: called with run-time arguments it is an ordinary function.
  • consteval forbids the run-time fallback and constinit forbids dynamic initialisation — the two guarantees constexpr deliberately does not give.
  • The unique capability is producing values the type system needs — array bounds, template arguments, types — which no optimizer may supply.
  • The performance argument is mostly about startup and guarantees, not throughput; an optimizer already folds much of this, but is never obliged to.
  • The costs are build time, an entire second implementation of the language inside the compiler, no debugger, and an obligation to compute exactly what the target would have computed.

Three keywords, three different obligations

specThese are the guarantees the language definitions make, and they are stable in a way the rest of this lesson is not. What is version-sensitive is the *size of the subset*: what a constexpr function may contain has grown at every C++ revision (loops in C++14, allocation and virtual calls in C++20, constexpr std::string and std::vector in C++20 with library support arriving unevenly), and Rust's const subset expands most releases. Code that does not compile as const today may compile as const next year; the reverse never happens.

The most common confusion in this area is treating constexpr as a promise. It is not: constexpr on a function means "this function is *eligible* to be evaluated at compile time, and there exists at least one set of arguments for which it can be". Call it with a run-time value and it is an ordinary function. That permissiveness is deliberate — it lets one definition serve both worlds — and it is why a constexpr function can silently end up running at startup instead of never running at all.

C++20 added the two keywords that turn the permission into a guarantee, in each of the two directions people actually wanted. consteval means the function may *only* be called in a constant expression; a run-time call is a compile error, which is what you want for a function that exists to generate something. constinit says nothing about when a function runs, but guarantees a variable is initialised at compile time — its purpose is to eliminate dynamic initialisation, and with it the static initialisation order fiasco, without making the variable const.

Rust draws the line in a third place. A const fn may be called in a constant context and is checked against the subset at definition; the subset itself has grown release by release as more of the language became const-evaluable. Zig collapses the distinction entirely: comptime marks an expression, a parameter or a block to be evaluated during compilation, and the same evaluator that folds arithmetic is also the language's generics mechanism — a generic function is one that takes a comptime type parameter.

What each construct actually promisesspec
ConstructPromisesIf it cannot be done at compile timeDoes not promise
C++ constexpr functionEligible for constant evaluation for some argumentsFalls back to an ordinary run-time callThat any particular call is evaluated early
C++ constexpr variableIts initializer is a constant expressionThe program is ill-formedAnything about other uses of the same function
C++ consteval functionEvery call is evaluated at compile timeThe program is ill-formedThat the function is usable at run time at all
C++ constinit variableInitialised before any dynamic initialisation runsThe program is ill-formedThat the variable is immutable — it is not const
Rust const fnCallable in a const context; body inside the const subsetRejected at definition, not at the callThat a normal call is folded — that is the optimizer's business
Zig comptimeThe marked expression is evaluated during compilationA compile error naming the run-time operationThat the result is small — it can emit a great deal of code

Moving work across the boundary

The point of all of it is a transfer: work that would have happened once per process start, or once per call, happens once per build instead. A lookup table computed by a loop at startup becomes a blob in the read-only data section. A parsed format string becomes a set of already-selected branches. A checksum over a constant becomes a literal.

What is bought is real but narrower than it looks. The saving is startup time and, for tables, the ability for the linker and loader to share the pages, which matters most where processes are short-lived and never amortise anything — see [[compile-time-vs-runtime]]. It is *not* generally a throughput win, because a good optimizer would fold much of this anyway under [[constant-folding]] and [[partial-evaluation]]. The difference is that constant evaluation is guaranteed and diagnosable, whereas folding is a best effort the compiler is entitled to skip.

And there is one thing constant evaluation can do that no optimizer may: produce a value the *type system* needs. An array bound, a template argument, a static_assert condition, a Zig type — none of those can wait for the optimizer, because the program cannot be checked without them. That is the capability, as opposed to the performance argument, and it is the reason the feature exists at all.

A table built at startup, moved into the binary
Before
static std::array<uint8_t, 256> table;          // zero-initialised
static const int init = [] {                     // runs before main()
  for (int i = 0; i < 256; ++i) table[i] = popcount(i);
  return 0;
}();
After
constexpr std::array<uint8_t, 256> make_table() {
  std::array<uint8_t, 256> t{};
  for (int i = 0; i < 256; ++i) t[i] = popcount(i);
  return t;
}
constinit auto table = make_table();             // a blob in .rodata
Legal only when

Only if every operation in make_table is inside the constant-expression subset — no I/O, no reads of non-constant globals, no undefined behavior on any executed path — and the evaluation completes within the implementation's step and depth limits. The result must also be a literal type whose value the compiler can serialise into the object file; a type containing a pointer to something whose address is not known until link time cannot make the trip.

Illegal when

If any entry depends on something only the running process knows — the current time, an environment variable, the address of another object, a configuration file — the initialiser is not a constant expression and the program is rejected rather than quietly falling back, because constinit was requested. Rewriting the same table for 65,536 entries is legal and a different kind of mistake: the evaluation runs inside the compiler on every build and can exceed -fconstexpr-steps, turning a run-time cost into a build failure.

Zig's answer: one evaluator, used for everything

C++ arrived at compile-time computation twice, from two directions, and kept both. Templates were discovered to be Turing-complete and became a metaprogramming language by accident, with types as values and recursion as iteration; constexpr was then added as a way to write compile-time code that looks like ordinary code. The two systems have different syntax, different capabilities and different error messages, and every C++ programmer eventually has to know both.

Zig starts from the other end. There is one evaluator, and comptime marks what runs in it. A generic data structure is a function that takes a comptime type parameter and returns a type — ordinary code, ordinary control flow, ordinary debugging, evaluated during compilation. There is no second language, so there is no second set of error messages, and a compile-time function can be read by anyone who can read the run-time one.

The cost is that the boundary between the two worlds is now everywhere rather than in a few marked places, and errors from the evaluator are about *your* code executing rather than about a type not satisfying a constraint. It is a genuinely different trade rather than a strictly better one — and it is the same trade [[partial-evaluation]] describes in general terms, adopted as the organising principle of a language.

What it costs, including one cost nobody expects

targetHost-versus-target arithmetic agreement is the compiler's obligation, not the language's: implementations use arbitrary-precision or emulated target arithmetic internally so that folding on an x86-64 host produces the value an AArch64 or a 32-bit embedded target would compute. Where it has gone wrong historically is extended-precision floating point and long double, whose representation differs between hosts. A constant-heavy program is one of the few places where a cross-compiled build can legitimately differ from a native one, and [[reproducible-compilation]] is the discipline that catches it.

The obvious costs are build time and compiler complexity. Build time, because the interpreter is slower than the compiled code would have been by a factor that can reach thousands, and it runs on every build until an incremental system caches the result. Compiler complexity, because the compiler now contains a second implementation of the language's semantics, with its own object model and lifetime tracking, and every language feature must be considered twice.

The unexpected cost is agreement. The constant evaluator runs on the *host* machine; the generated code runs on the *target*. Every value the evaluator produces must be the value the target would have produced — same integer width, same signedness, same floating-point rounding, same size_t, same endianness for anything that gets serialised. Compilers therefore emulate target arithmetic rather than using the host's, and the places where that emulation has historically been imperfect are exactly the places cross-compiled binaries have differed from native ones. This is invisible until you cross-compile, and then it is a bug with no good symptom.

Then there is debuggability. There is no debugger for the constant evaluator. You cannot set a breakpoint in a consteval function; the tools are static_assert, and in C++ a deliberate compile error that prints the value you wanted to see. Zig has @compileLog, Rust has const panics with messages. All of them are printf debugging with a slow edit-compile loop, and that is the state of the art.

How it works

The steps, in the order the compiler takes them.

  • The frontend marks expressions that are required to be constant — array bounds, template arguments, static_assert conditions, constexpr and constinit initialisers — and expressions that are merely eligible.
  • For each, the constant evaluator interprets the typed AST against an abstract machine with a modelled heap, object lifetimes, initialisation state, a call-depth limit and a step budget.
  • Arithmetic is performed in target semantics rather than host semantics, so the value produced is the value the generated code would have produced.
  • Any operation outside the subset — I/O, reading a non-constant object, undefined behavior, exceeding a limit — aborts the evaluation with a diagnostic naming the offending operation.
  • On abort, a required constant expression makes the program ill-formed; an eligible one falls back to ordinary code generation for a run-time call.
  • A successful evaluation yields a typed value, which becomes a template argument, a case label, an array bound, or an initialised object serialised into the constants section of the object file.
  • Compile-time-only functions (consteval, Zig comptime) emit no run-time code at all; their entire existence is inside the compiler.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A constexpr function is quietly evaluated at run time because one caller passed a non-constant argument, and startup gets slower with nothing in the diagnostics to say so.
  • Build time doubles after a metaprogramming-heavy header lands, and -ftime-trace attributes it to constant evaluation rather than to instantiation or optimization.
  • The compiler reports that the constexpr step limit was exceeded and suggests a flag, on a build that worked yesterday, because a table got one dimension larger.
  • A cross-compiled binary computes a slightly different constant from the native build of the same source, and the difference surfaces as a checksum mismatch far from the cause.
  • An expression that "obviously" folds does not, because one operand reads a global the compiler cannot prove constant, and the profile shows work at startup that the author believed was in the binary.
  • A constexpr function is refactored to add a diagnostic printf, and every use of it as a template argument fails at once with errors that name the callers rather than the change.

When it helps

  • Eliminating dynamic initialisation from a library, which removes both startup cost and the static initialisation order problem — the point of constinit.
  • Producing values the type system requires: array bounds, dimensions, dispatch tables selected by type, and anything that must be known before checking can finish.
  • Validating configuration and format strings at build time so a malformed one is a compile error rather than a run-time exception.
  • Short-lived processes — CLIs, serverless handlers — where startup work is a large fraction of total time and never amortises.

When it hurts

  • Large computations moved into the compiler, where they run interpreted, on every build, and are paid by every engineer on the team rather than once per process.
  • Code written to be constant-evaluable at the cost of readability, when the optimizer would have folded it anyway and the guarantee was never needed.
  • Anything you will need to debug. There is no debugger for the constant evaluator, and the substitute is a deliberate compile error.

What it costs

Every one of these is paid by something.

  • Moving work to compile time buys startup latency, shareable read-only pages and a guarantee rather than a hope, and pays with build time on every build for every engineer, multiplied by the interpretation overhead.
  • Making a function constexpr buys eligibility in both worlds from one definition, and pays by making the run-time fallback silent — the case people most want a diagnostic for.
  • Having a constant evaluator at all buys the ability to compute types and bounds, and costs the compiler a second, complete implementation of the language's semantics that must agree with the first.
  • Requiring target-accurate arithmetic in the evaluator buys cross-compilation that produces the same values as a native build, and pays with an emulated arithmetic layer inside the compiler and a class of bug that only appears when the host and target differ.
  • Zig's single-evaluator design buys one language instead of two and readable metaprogramming, and pays by putting the compile-time boundary everywhere rather than in a few marked constructs.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Code generation from an external script produces ordinary source the compiler checks normally — slower to wire into a build, far easier to debug, and the errors are about generated code you can read.
  • Runtime memoisation or a lazily built table costs a branch per access and keeps the build fast; for anything computed once and used millions of times the difference is unmeasurable.
  • Template metaprogramming reaches many of the same results in C++ without constexpr, expressed as type computation rather than as code — more portable across old standards, much worse diagnostics. See [[templates]].
  • Leaving it to the optimizer via [[constant-folding]] and [[partial-evaluation]] costs nothing to write and gives no guarantee, which is fine everywhere the value is not needed by the type system.
  • Rust's procedural macros run arbitrary Rust at build time in a separate compilation, which is strictly more powerful than const fn and pays with build dependencies, sandboxing questions and a much larger trust surface — see [[rust-pipeline]].

See it for yourself

The flag, dump or tool that shows you this directly.

  • static_assert(f(3) == 9); is the direct test that a function is constant-evaluable with those arguments; the failure message names the first operation that was not.
  • clang -Xclang -fconstexpr-steps=N and GCC's -fconstexpr-ops-limit=N raise or lower the evaluation budget — lowering it deliberately is how you find out how much work is being done.
  • clang -ftime-trace reports constant evaluation as its own span, separately from template instantiation, which is how you tell the two build-time costs apart.
  • objdump -s -j .rodata binary shows the bytes of the tables that made the trip; if what you expected is not there, the evaluation fell back.
  • Compiler Explorer with -O0: anything still computed in the assembly at -O0 was not constant-evaluated, since no optimizer ran to fold it.
  • Zig: @compileLog prints values from the evaluator. Rust: const items that fail evaluation produce a compile error with the panic message.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "constexpr means it runs at compile time." It means it *may*. With a run-time argument it is an ordinary function call, and nothing warns you.
  • "Compile-time evaluation makes the program faster." It removes startup work and guarantees folding. Steady-state throughput is usually unchanged, because the optimizer folded the same expressions anyway.
  • "If it compiles, it was evaluated early." Only if the context required a constant. static_assert or inspecting .rodata is the check; the keyword is not.
  • "More constexpr is strictly better." Each one is work moved into a build that every engineer pays on every change, running interpreted, with no debugger.
  • "Constant folding and constant evaluation are the same thing." Folding is an optimization the compiler may skip; evaluation is a semantic requirement that must succeed or the program is rejected.

Misconceptions

The claim, and what is actually true.

Marking everything constexpr costs nothing.
It costs build time whenever the evaluation actually happens, and it widens the interface: a constexpr function's body is part of what callers depend on, so changing it can break a static_assert in someone else's code.
Compile-time evaluation is just aggressive constant folding.
Folding is optional and invisible; evaluation is mandatory in the contexts that require it, diagnosable when it fails, and can produce types and bounds that folding could never supply because the program cannot be checked without them.
Zig's comptime is a macro system.
A macro operates on syntax before checking. comptime runs ordinary, type-checked Zig in the compiler's own evaluator, which is why its errors are about values and types rather than about expanded text.
A consteval function is a constexpr function that is better optimized.
It is a function that has no run-time existence at all. Calling it outside a constant expression is an error, which is the entire point — the guarantee, not the code quality.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

Some expressions can be worked out while the program is being built rather than while it is running. constexpr says a function is allowed to be used that way; consteval says it must be; constinit says a variable must get its value before the program starts. The work does not vanish — it moves into your build, where it runs slowly, on every compile.

practical

Use constinit to kill dynamic initialisation in libraries, and consteval when a function exists only to generate something and a run-time call would be a bug. Do not sprinkle constexpr for speed: check with static_assert whether the call you care about is actually evaluated, and check -ftime-trace for what the compile-time work is costing. If a compile-time computation is big enough to need debugging, it is big enough to be a code generator in a script instead.

advanced

The design question underneath all of this is where the boundary between the two evaluation times should live and who gets to move it. C++ made it a property of declarations, so it is explicit, checkable and duplicated — templates and constexpr are two metaprogramming systems with different rules. Zig made it a property of expressions and unified the two, gaining one language and losing the sharp separation that makes C++ signatures tell you which world a function belongs to. Rust made it a conservative subset that grows by RFC, plus a completely separate escape hatch — procedural macros, which are ordinary Rust programs compiled and executed by the build — which trades the elegance for an explicit trust and dependency boundary you can see in the manifest. All three are answers to the same question, and the interesting comparison is not which is most powerful but which one tells you, from a signature, when your code runs.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

specThe guarantees — constexpr may fall back, consteval may not, a constexpr variable's initializer must be a constant expression — are specified by the C++ standard and portable. What is not portable is the reachable subset: allocation, virtual calls, try/catch and std::string became constant-evaluable at different standard revisions and were implemented at different times by GCC, Clang and MSVC, so identical source can be a constant expression under one toolchain and not another.
implementationEvaluation limits are implementation-defined and differ: Clang defaults to a step budget adjustable with -fconstexpr-steps and a depth limit with -fconstexpr-depth, GCC uses -fconstexpr-ops-limit and -fconstexpr-depth, and MSVC has its own. A metaprogram that compiles on one toolchain can exceed a limit on another with an error that says nothing about the design being wrong.
targetThe evaluator must compute what the target would compute, so implementations emulate target integer widths and floating-point behaviour rather than using the host's. Where this has historically been imperfect is extended-precision floating point; a constant-heavy program is one of the few places a cross-compiled build can legitimately differ from a native build of the same source.
typicalThat constant evaluation is orders of magnitude slower than the equivalent compiled code is a property of every mainstream implementation, all of which interpret the AST rather than compiling it. It is not required: an implementation could JIT its own constant evaluator, and some research compilers have, which would change the build-time argument in this lesson substantially.

If you were asked this in an interview

  • What does constexpr actually guarantee, and how would you check whether a specific call was evaluated at compile time?
  • Why does a compiler emulate target arithmetic in its constant evaluator instead of using the host's?
  • When would you move a computation into the build, and when is that the wrong call?

Connections

Performancebenchmarking
OS & Networkingprocess-memory-layout
Domains that do not exist yet
  • DevOps / Production Engineering — Build time as a shared cost, and caching the results of expensive compilation
    Every constant evaluation is paid by every engineer on every build until something caches it. Deciding how much team time a compile-time guarantee is worth, and building the cache that makes the answer different, is a build-engineering decision rather than a language one.