Compile-Time Evaluation: constexpr, consteval, and constinit
C++20 compile-time evaluation: when constexpr runs at compile vs runtime, how consteval forces it, constinit's role in static initialization order, and std::is_constant_evaluated.
C++20 has four const-related keywords for controlling when and how values are initialized or evaluated. They are easy to confuse β and their interactions have real consequences for compile-time overhead, link-time errors, and static initialization order bugs.
The four keywords at a glance
| Keyword | Applies to | Guarantees | Use for |
|---|---|---|---|
const | objects, member functions | immutability (no writes after init) | Read-only values; runtime OK |
constexpr | objects, functions | usable in constant expressions; may run at runtime | Compile-time eligible code |
consteval | functions only | must run at compile time | Always-compile-time logic (like macros) |
constinit | static/thread_local objects | initialized at compile time, but mutable | Fix static init order fiasco |
const β immutability, not compile-time
const means "this object will not be modified after initialization." It says nothing about when initialization happens.
const int n = 42; // may be compile-time (integral const + literal)
const int m = compute(); // definitely runtime (function call)
const std::string s = "hi"; // runtime (std::string constructor runs at runtime)
// const integral types initialized with a constant expression can be
// used as a constant expression (a historical special case):
int arr[n]; // OK: n is a const integral type with constant initializer
int arr2[m]; // error: m is not usable in a constant expressionThe historical special case for const int initialized with a literal is a C++03 compatibility rule. Don't rely on it for new code β use constexpr instead.
constexpr β eligible for constant expressions
constexpr tells the compiler: "this can be evaluated in a constant expression context." The compiler may evaluate it at compile time but is not required to β unless the result is used in a context that requires a constant expression (template argument, array size, static_assert, constexpr variable initializer).
constexpr functions
constexpr int factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
// Used in constant expression context β evaluated at compile time:
constexpr int f5 = factorial(5); // f5 = 120, computed by the compiler
static_assert(f5 == 120);
// Used at runtime β may or may not be evaluated at compile time:
int n = read_from_user();
int result = factorial(n); // runtime callWhat constexpr functions can do (evolving restrictions)
| Feature | Since |
|---|---|
| Multiple statements, loops, local variables | C++14 |
if constexpr inside the body | C++17 |
try/catch in the body (cannot actually throw in constexpr context) | C++20 |
std::string, std::vector in constexpr context | C++20 |
virtual functions called in constexpr context | C++20 |
C++20 dramatically expanded constexpr. String and vector operations are now usable in constant expressions, enabling real algorithms:
#include <string>
#include <algorithm>
constexpr std::string reverse_string(std::string_view sv) {
std::string result(sv);
std::reverse(result.begin(), result.end());
return result;
}
static_assert(reverse_string("hello") == "olleh");constexpr objects
A constexpr object is implicitly const and must be initialized with a constant expression:
constexpr int x = 10; // OK
constexpr int y = x * 2; // OK: x is a constant expression
// constexpr int z = rand(); // error: rand() is not constexpr
constexpr auto arr = std::array{1, 2, 3, 4, 5}; // constexpr array
static_assert(arr[2] == 3);consteval β guaranteed compile time (C++20)
consteval declares an immediate function: every call must be evaluated at compile time. If the arguments are not constant expressions, the program is ill-formed.
consteval int square(int n) { return n * n; }
constexpr int a = square(5); // OK: 5 is a constant expression β result = 25
int x = 10;
int b = square(x); // error: x is not a constant expressionWhen to use consteval over constexpr:
- When the function only makes sense at compile time (format string validation, compile-time lookup tables, type computation shims)
- When you want to ensure the compiler catches incorrect use early β a
constexprfunction called at runtime gives no diagnostic
// Compile-time string hash β meaningless at runtime
consteval std::size_t hash_str(std::string_view s) {
std::size_t h = 0xcbf29ce484222325ULL;
for (char c : s) h = (h ^ c) * 0x100000001b3ULL;
return h;
}
// Use as a map key at compile time:
static_assert(hash_str("GET") != hash_str("POST"));
constexpr auto GET_HASH = hash_str("GET");consteval for diagnostic-friendly APIs: a common pattern is wrapping a constexpr variable in a consteval getter to get compile-time errors at the call site:
template<int N>
consteval int must_be_positive() {
static_assert(N > 0, "N must be positive");
return N;
}
auto v = must_be_positive<-1>(); // clear compile-time errorstd::is_constant_evaluated() (C++20)
A function can detect whether it is being evaluated at compile time and branch accordingly:
#include <type_traits>
constexpr double fast_log(double x) {
if (std::is_constant_evaluated()) {
// Compile-time path: use a simple series expansion
// (std::log is not constexpr in most implementations)
return /* series */ x - 1.0; // simplified example
} else {
// Runtime path: use the hardware instruction
return std::log(x);
}
}This allows a single function to use different algorithms depending on context β a simple compile-time implementation for constant folding, and the fast hardware path at runtime.
C++23 if consteval β cleaner syntax for the same pattern:
constexpr double fast_log(double x) {
if consteval {
return /* compile-time approximation */ x - 1.0;
} else {
return std::log(x);
}
}constinit β constant initialization, mutable (C++20)
constinit asserts that a static or thread_local variable is initialized at compile time (constant initialization) β but unlike constexpr, the variable is not const and can be modified at runtime.
constexpr int cube(int n) { return n * n * n; }
constinit int cache_size = cube(10); // initialized at compile time: 1000
// later:
cache_size = 512; // OK: constinit doesn't mean constPrimary use: fixing the static initialization order fiasco.
The static initialization order fiasco occurs when one static variable depends on another that hasn't been initialized yet (across translation units, order is undefined):
// file_a.cpp:
int base_value = 100;
// file_b.cpp:
extern int base_value;
int derived_value = base_value * 2; // UB if file_b initializes before file_a!constinit forces constant initialization β which happens before dynamic initialization and has a defined order:
// file_b.cpp: (fixing with constinit)
constinit int derived_value = 200; // initialized at compile time: no order dependencyIf the initializer can't be evaluated at compile time, constinit causes a compile error β which is exactly what you want: it catches the problem at compile time rather than producing a mysterious runtime bug.
constinit vs constexpr for globals:
constexpr int A = 100; // constant, can't be changed
constinit int B = 100; // mutable, but initialized safely before any dynamic initUse constexpr when the value is logically a constant. Use constinit when the variable must be modified at runtime but must be safely initialized at compile time.
Compile-time algorithms: practical patterns
Compile-time lookup table
#include <array>
consteval std::array<int, 256> build_crc_table() {
std::array<int, 256> table{};
for (int i = 0; i < 256; ++i) {
int crc = i;
for (int j = 0; j < 8; ++j)
crc = (crc >> 1) ^ (crc & 1 ? 0xEDB88320 : 0);
table[i] = crc;
}
return table;
}
// Table is computed once by the compiler; zero runtime cost:
constexpr auto CRC_TABLE = build_crc_table();
uint32_t crc32(std::span<const uint8_t> data) {
uint32_t crc = 0xFFFFFFFF;
for (uint8_t b : data)
crc = (crc >> 8) ^ CRC_TABLE[(crc ^ b) & 0xFF];
return ~crc;
}Compile-time string parsing / format validation
// Validate a printf-style format string at compile time:
consteval bool is_valid_format(std::string_view fmt, int expected_args) {
int count = 0;
for (std::size_t i = 0; i < fmt.size(); ++i) {
if (fmt[i] == '%' && i + 1 < fmt.size() && fmt[i+1] != '%')
++count;
}
return count == expected_args;
}
template<typename... Args>
void safe_printf(const char* fmt, Args... args) {
static_assert(is_valid_format(fmt, sizeof...(Args)),
"format string arg count mismatch");
// ...
}Compiler output: checking what actually runs at compile time
Use godbolt.org or check the assembly:
# If factorial(5) is in the assembly as an immediate value (120), it ran at compile-time:
g++ -O2 -std=c++20 -c test.cpp && objdump -d test.o | grep '120'
# Better: put result in a constexpr:
constexpr int f5 = factorial(5); // guaranteed compile-timestatic_assert is your best tool β if the static_assert compiles, the expression was evaluated at compile time:
static_assert(factorial(5) == 120); // proves factorial(5) runs at compile timeQuick summary
Need a read-only value?
β const (runtime OK) or constexpr (use in templates/static_assert)
Need a function callable in both compile-time and runtime contexts?
β constexpr function
Need a function that MUST run at compile time (error if called at runtime)?
β consteval
Need a global that initializes at compile time but can be mutated?
β constinit (solves static init order fiasco)
Need to branch on whether you're in constexpr context?
β std::is_constant_evaluated() or C++23 if constevalconsteval for validated format strings
consteval lets you reject malformed format strings at compile time, turning a class of runtime bugs into compile errors. The idea is that a format string is a compile-time constant; a consteval validator can inspect every character and issue a static_assert or trigger a compilation error before the binary is linked.
#include <string_view>
#include <stdexcept>
// Count format specifiers matching a given type pack β simplified illustration:
consteval int count_specifiers(std::string_view fmt) {
int count = 0;
for (std::size_t i = 0; i + 1 < fmt.size(); ++i) {
if (fmt[i] == '{' && fmt[i+1] == '}') {
++count;
++i; // skip '}'
}
}
return count;
}
// Wrapper that validates arg count at compile time:
template<typename... Args>
void checked_print(std::string_view fmt, Args&&... args) {
// consteval call: if fmt is not a constant expression this fails to compile
static_assert(
count_specifiers(fmt) == sizeof...(Args),
"format specifier count does not match argument count"
);
// ... actual formatting logic
}
// Usage:
// checked_print("{} + {} = {}", 1, 2, 3); // OK: 3 specifiers, 3 args
// checked_print("{} + {}", 1, 2, 3); // compile error: 2 != 3std::format in C++20 (and std::print in C++23) apply exactly this technique internally: the format string argument is processed by a consteval constructor in std::format_string<Args...>, which fails compilation when the specifiers do not match the argument types. You can replicate the pattern for domain-specific languages β SQL snippets, regex patterns, URL templates β any string whose validity can be checked structurally at compile time.
Compile-time data structures
constexpr std::array and constexpr algorithms allow building sorted lookup tables, perfect hash maps, and other data structures entirely at compile time. Since C++20 you can also sort, search, and transform arrays inside constexpr and consteval functions using <algorithm>.
#include <array>
#include <algorithm>
#include <string_view>
struct KeyValue { std::string_view key; int value; };
// Build a sorted table at compile time for binary search:
consteval auto make_sorted_table() {
std::array<KeyValue, 4> table = {{
{"zebra", 4},
{"apple", 1},
{"mango", 3},
{"banana", 2},
}};
// std::sort is constexpr since C++20:
std::sort(table.begin(), table.end(),
[](const KeyValue& a, const KeyValue& b) {
return a.key < b.key;
});
return table;
}
constexpr auto TABLE = make_sorted_table();
// TABLE is now: apple=1, banana=2, mango=3, zebra=4 (sorted at compile time)
constexpr int lookup(std::string_view key) {
auto it = std::lower_bound(TABLE.begin(), TABLE.end(), key,
[](const KeyValue& kv, std::string_view k) { return kv.key < k; });
if (it != TABLE.end() && it->key == key) return it->value;
return -1; // not found
}
static_assert(lookup("mango") == 3);
static_assert(lookup("apple") == 1);
static_assert(lookup("cherry") == -1);The pattern eliminates the need for runtime sorting or hash-map construction for static datasets. The compiled binary contains only the already-sorted array, and lookup compiles to a binary search with no initialization cost.
std::bit_cast β type-punning without UB (C++20)
std::bit_cast<T>(value) reinterprets the object representation of value as type T, with no undefined behaviour, and is usable in constexpr context. Before C++20 the only safe way to type-pun was std::memcpy, which is not constexpr. reinterpret_cast and union-based type punning are both undefined behaviour in C++.
#include <bit>
#include <cstdint>
#include <cassert>
// Classic "fast inverse square root" floatβint bit manipulation, UB-free:
constexpr float q_rsqrt(float number) {
std::uint32_t i = std::bit_cast<std::uint32_t>(number);
i = 0x5f3759df - (i >> 1);
float y = std::bit_cast<float>(i);
return y * (1.5f - (number * 0.5f * y * y));
}
// Usable in constexpr context:
static_assert(q_rsqrt(4.0f) > 0.4f && q_rsqrt(4.0f) < 0.6f);
// Inspect the raw bytes of a double at compile time:
consteval std::uint64_t double_bits(double d) {
return std::bit_cast<std::uint64_t>(d);
}
// IEEE 754: positive infinity has exponent all-ones, mantissa zero:
static_assert(double_bits(1.0 / 0.0) == 0x7FF0000000000000ULL);std::bit_cast requires sizeof(From) == sizeof(To) and both types to be trivially copyable β the compiler enforces this at compile time. It is also strictly defined to produce a result in constant evaluation: the compiler can evaluate it and fold the result into a constant, enabling compile-time inspection of floating-point encoding, network byte-order conversions, and SIMD layout computations.
if consteval β cleaner compile-time branching (C++23)
if consteval is the C++23 replacement for std::is_constant_evaluated(), providing syntactically cleaner branching between compile-time and runtime code paths. The older idiom had a subtle footgun: std::is_constant_evaluated() called inside a non-constexpr function always returns false, and calling it outside an if constexpr could be silently wrong.
// C++20 approach β subtly error-prone:
constexpr int compute(int x) {
if (std::is_constant_evaluated()) {
// Note: if you accidentally write
// if constexpr (std::is_constant_evaluated())
// that's always true in a constexpr function and always false elsewhere β
// a common mistake.
return x * x; // compile-time path
}
return expensive_runtime_impl(x); // runtime path
}
// C++23 approach β unambiguous:
constexpr int compute_v2(int x) {
if consteval {
return x * x; // compile-time path
} else {
return expensive_runtime_impl(x); // runtime path
}
}
// 'if consteval' also enables calling consteval functions from constexpr functions:
consteval int must_compile_time(int x) { return x * 2; }
constexpr int wrapper(int x) {
if consteval {
return must_compile_time(x); // OK: we're provably in compile-time context
} else {
return x * 2; // runtime fallback
}
}
static_assert(wrapper(5) == 10); // compile-time path takenThe if consteval body is itself a "manifestly constant-evaluated context," so you can call consteval functions inside it β something that is illegal in ordinary constexpr function bodies. This makes if consteval the correct tool for hybrid functions that want to use heavy compile-time machinery when available and fall back to runtime implementations otherwise.
Limits of constant evaluation
The compiler's constant evaluator is a full interpreter, but it deliberately prohibits operations that are non-deterministic, have side effects, or would require OS resources. Hitting these limits produces a compile error rather than silent wrong behavior.
Key restrictions and their rationale:
// 1. I/O is forbidden β the compiler has no stdout at constant-eval time:
constexpr void bad() {
std::printf("hello"); // error: not constexpr-callable
}
// 2. Raw 'new'/'delete' are allowed in constexpr context only if the
// allocation does not "escape" into the final constant object:
constexpr int sum_heap() {
int* p = new int[3]{1, 2, 3}; // OK: allocation tracked by the evaluator
int s = p[0] + p[1] + p[2];
delete[] p; // must be freed before function returns
return s; // result is a plain int: no pointer escapes
}
static_assert(sum_heap() == 6);
// But a constexpr object cannot HOLD a heap-allocated pointer:
// constexpr int* leaked = new int(42); // error: allocation escapes constant
// 3. Undefined behaviour is a compile error in constexpr context:
constexpr int out_of_bounds() {
int arr[3] = {1, 2, 3};
return arr[5]; // error: out-of-bounds access detected by the evaluator
}
// 4. reinterpret_cast is forbidden:
constexpr void bad_cast(float f) {
auto i = reinterpret_cast<int*>(&f); // error: use std::bit_cast instead
}
// 5. Volatile reads/writes are forbidden:
constexpr void bad_volatile() {
volatile int x = 1; // error: volatile in constexpr context
}
// 6. System calls, thread operations, longjmp β all forbidden.The evaluator's UB detection is a significant benefit: it catches array overruns, null dereferences, and signed integer overflow in constexpr code that might silently produce wrong answers at runtime. Running algorithms in constexpr context is a free correctness check.
Compile-time unit tests with static_assert
static_assert turns constexpr functions into zero-overhead unit tests that run at compile time and catch regressions before the binary is built. Unlike runtime test frameworks, these assertions disappear entirely from the final binary when they pass.
// A constexpr utility under test:
constexpr int clamp(int value, int lo, int hi) {
return value < lo ? lo : value > hi ? hi : value;
}
// Compile-time test suite β all evaluated at compile time, zero runtime cost:
namespace test_clamp {
static_assert(clamp(5, 1, 10) == 5, "value in range should be unchanged");
static_assert(clamp(0, 1, 10) == 1, "value below lo should be clamped to lo");
static_assert(clamp(15, 1, 10) == 10, "value above hi should be clamped to hi");
static_assert(clamp(1, 1, 10) == 1, "lo boundary is inclusive");
static_assert(clamp(10, 1, 10) == 10, "hi boundary is inclusive");
static_assert(clamp(5, 5, 5) == 5, "degenerate range: lo == hi");
}
// For consteval functions, wrap the assertion in a consteval helper
// so it is forced to run at compile time even without an explicit constexpr variable:
consteval bool verify(bool condition, const char* /*msg*/) {
if (!condition) throw std::logic_error("test failed"); // compile error on failure
return true;
}
consteval bool run_clamp_tests() {
verify(clamp(0, 1, 10) == 1, "below lo");
verify(clamp(15, 1, 10) == 10, "above hi");
return true;
}
static_assert(run_clamp_tests());
// For richer output, encode the failing case in the type system:
template<int Got, int Expected>
struct MismatchError; // intentionally undefined
template<int N>
consteval void expect_eq(int got) {
if (got != N) {
// This instantiation fails to compile, printing Got and Expected in the error:
(void) MismatchError<-1, N>{};
}
}Placing compile-time tests in a namespace test_* block inside the same header as the function means they run on every rebuild and cannot be accidentally skipped. They are especially valuable for constexpr algorithms (sorting, hashing, parsing) where the compile-time evaluator's UB detection catches bugs that runtime tests on release builds might miss.