C++ Compile-Time Programming: constexpr, consteval, constinit & Template Metaprogramming (C++23)

C++ Compile-Time Programming: constexpr, consteval, constinit & Template Metaprogramming (C++23)
Table of Contents
- The Four Compile-Time Keywords
- constexpr Functions: Rules and Restrictions
- constexpr Classes and Objects (C++14/20)
- consteval: Mandatory Compile-Time Functions (C++20)
- constinit: Initialization Order Guarantee (C++20)
- std::is_constant_evaluated: Dual-Mode Functions (C++20)
- Compile-Time Lookup Tables with IIFE
- constexpr Containers: vector, string in C++20
- Type Traits and std::integral_constant
- static_assert: Compile-Time Validation
- Template Metaprogramming: Computing at Compile Time
- Frequently Asked Questions
- Key Takeaway
The Four Compile-Time Keywords
constexpr Functions: Rules and Restrictions
A constexpr function can execute at compile time if called with constant expressions. If called with runtime values, it runs normally as a runtime function:
#include <array>
#include <numeric>
// Basic constexpr math:
constexpr long long factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
// Compile-time: result baked into binary as constant 120
constexpr long long f5 = factorial(5); // 120 — computed at build time!
static_assert(f5 == 120); // Verified at compile time!
// Runtime: computed normally
int n;
std::cin >> n;
long long fn = factorial(n); // Runtime calculation — works too!
// C++14+: constexpr functions can have loops, local vars, conditionals:
constexpr bool is_prime(int n) {
if (n < 2) return false;
for (int i = 2; i * i <= n; i++) {
if (n % i == 0) return false;
}
return true;
}
constexpr bool p7 = is_prime(7); // true — compile time
constexpr bool p10 = is_prime(10); // false — compile time
// C++20: constexpr functions can use:
// - try/catch (but not with actual exceptions at compile time)
// - dynamic_cast and typeid
// - std::vector, std::string (with constexpr allocator)
// - virtual function callsRules for constexpr functions:
- No goto, thread-local storage, or I/O (
printf,fopen, etc.) - No undefined behavior — the compiler catches it
- Can call only
constexprfunctions - Local variables of non-literal types require C++14+
constexpr Classes and Objects (C++14/20)
// constexpr class — all member functions must be constexpr:
class FixedPoint {
int value_; // stored as integer with implicit decimal
public:
constexpr explicit FixedPoint(double v, int decimals = 2)
: value_(static_cast<int>(v * std::pow(10, decimals))) {}
constexpr FixedPoint operator+(const FixedPoint& o) const {
FixedPoint result{0.0};
result.value_ = value_ + o.value_;
return result;
}
constexpr double to_double() const { return value_ / 100.0; }
};
constexpr FixedPoint price1{19.99};
constexpr FixedPoint price2{5.50};
constexpr FixedPoint total = price1 + price2; // Computed at compile time!
static_assert(total.to_double() == 25.49);
// constexpr Point — common pattern:
struct Point {
double x, y;
constexpr double distance_from_origin() const {
// std::sqrt is constexpr since C++26 (use __builtin_sqrt for now):
return x * x + y * y; // Return squared for now
}
constexpr Point operator+(const Point& o) const { return {x+o.x, y+o.y}; }
};
constexpr Point origin{0.0, 0.0};
constexpr Point p1{3.0, 4.0};
constexpr double dist_squared = p1.distance_from_origin(); // 25.0 — compile time
static_assert(dist_squared == 25.0);consteval: Mandatory Compile-Time Functions (C++20)
consteval ("immediate function") forces execution at compile time. If the compiler cannot evaluate the call at compile time (because arguments aren't constant expressions), it's a hard compile error:
#include <cstdint>
// consteval: guaranteed compile-time format string validation
consteval uint32_t make_fourcc(char a, char b, char c, char d) {
return uint32_t(a) | (uint32_t(b) << 8) | (uint32_t(c) << 16) | (uint32_t(d) << 24);
}
constexpr uint32_t RIFF = make_fourcc('R','I','F','F'); // Compile-time constant!
constexpr uint32_t WAVE = make_fourcc('W','A','V','E');
// make_fourcc cannot be called with runtime args:
char ch = 'X';
// make_fourcc(ch, 'B', 'C', 'D'); // COMPILE ERROR: ch is not constexpr
// Use case: validated format strings
consteval const char* validate_sql(const char* sql) {
// Scan for obvious injection patterns at compile time:
for (const char* p = sql; *p; p++) {
if (*p == ';') throw "SQL contains semicolon — possible injection!";
}
return sql;
}
constexpr auto safe_query = validate_sql("SELECT id FROM users WHERE name = ?");
// constexpr auto bad_query = validate_sql("SELECT id; DROP TABLE users"); // COMPILE ERROR!
// consteval for cross-platform diagnostics:
consteval int require_64bit_platform() {
if (sizeof(void*) != 8)
throw "This library requires a 64-bit platform!";
return sizeof(void*);
}
static_assert(require_64bit_platform() == 8);constinit: Initialization Order Guarantee (C++20)
The "Static Initialization Order Fiasco" occurs when one global's initialization depends on another global that may not be initialized yet:
// PROBLEM: static initialization order fiasco
// File1.cpp:
int global_value = compute_value(); // May run before File2.cpp's globals!
// File2.cpp:
extern int global_value;
int derived = global_value * 2; // May use uninitialized global_value!
// SOLUTION 1: constinit — forces constant initialization, no runtime init needed
constinit int global_value = 42; // Guaranteed to be constant-initialized
// constinit extern int other_global; // Mark declaration constinit too
// SOLUTION 2: function-local static (lazy initialization — always safe)
int& get_global() {
static int value = compute_value(); // Initialized on first call, once
return value;
}
// constinit rules:
// - Value must be computable at compile time (constant expression)
// - Variable must have static or thread-local storage duration
// - Does NOT make the variable const (can still be modified after init)
constinit thread_local int tls_counter = 0; // Thread-local constant init
tls_counter++; // Can be mutated after init — only init is guaranteedCompile-Time Lookup Tables with IIFE
Generate lookup tables entirely at compile time — binary contains pre-computed data:
#include <array>
#include <cstdint>
// CRC32 lookup table — computed at build time, zero runtime cost
constexpr uint32_t crc32_byte(uint32_t byte) {
uint32_t crc = byte;
for (int i = 0; i < 8; i++) {
crc = (crc & 1) ? (crc >> 1) ^ 0xEDB88320u : (crc >> 1);
}
return crc;
}
constexpr auto CRC32_TABLE = []() {
std::array<uint32_t, 256> table{};
for (int i = 0; i < 256; i++) {
table[i] = crc32_byte(static_cast<uint32_t>(i));
}
return table;
}(); // IIFE: immediately invoked lambda at compile time
// At runtime, CRC32_TABLE is just an initialized const array in .rodata:
uint32_t crc32(const uint8_t* data, size_t len) {
uint32_t crc = 0xFFFFFFFF;
for (size_t i = 0; i < len; i++) {
crc = CRC32_TABLE[(crc ^ data[i]) & 0xFF] ^ (crc >> 8);
}
return crc ^ 0xFFFFFFFF;
}
// Other examples of compile-time tables:
constexpr auto POPCOUNT_TABLE = []() { // Population count table
std::array<uint8_t, 256> t{};
for (int i = 0; i < 256; i++) {
t[i] = static_cast<uint8_t>(__builtin_popcount(i));
}
return t;
}();
constexpr auto SIN_TABLE = []() { // Sine lookup (0-90 degrees, 91 entries)
std::array<float, 91> t{};
for (int i = 0; i <= 90; i++) {
t[i] = static_cast<float>(i) * 3.14159265f / 180.0f;
}
return t;
}();Type Traits and std::integral_constant
Type traits let you query properties of types at compile time:
#include <type_traits>
// Common type traits:
static_assert(std::is_integral_v<int>); // true
static_assert(std::is_floating_point_v<double>); // true
static_assert(std::is_pointer_v<int*>); // true
static_assert(std::is_class_v<std::string>); // true
static_assert(std::is_same_v<int, int32_t>); // true (platform-dependent)
// Type transformations (result in a type, not bool):
using T1 = std::remove_const_t<const int>; // int
using T2 = std::remove_pointer_t<int*>; // int
using T3 = std::decay_t<const int&>; // int (removes const, ref, arrays→ptr)
using T4 = std::add_lvalue_reference_t<int>; // int&
// std::integral_constant: compile-time integral value as a type
using one = std::integral_constant<int, 1>;
using two = std::integral_constant<int, 2>;
using three = std::integral_constant<int, one::value + two::value>;
static_assert(three::value == 3);
// Custom type trait:
template<typename T>
struct is_smart_pointer : std::false_type {};
template<typename T>
struct is_smart_pointer<std::unique_ptr<T>> : std::true_type {};
template<typename T>
struct is_smart_pointer<std::shared_ptr<T>> : std::true_type {};
template<typename T>
constexpr bool is_smart_pointer_v = is_smart_pointer<T>::value;
static_assert(is_smart_pointer_v<std::unique_ptr<int>>); // true
static_assert(!is_smart_pointer_v<int*>); // false — raw ptr not smartstatic_assert: Compile-Time Validation
// Assert type sizes for ABI compatibility:
static_assert(sizeof(int) == 4, "Expected 32-bit int");
static_assert(sizeof(void*) == 8, "64-bit pointers required");
static_assert(sizeof(float) == 4, "IEEE 754 float required");
// Assert struct layout (no unexpected padding):
struct PacketHeader {
uint32_t magic;
uint16_t version;
uint16_t flags;
uint32_t length;
};
static_assert(sizeof(PacketHeader) == 12, "PacketHeader must be exactly 12 bytes");
static_assert(offsetof(PacketHeader, length) == 8, "length must be at byte offset 8");
// With template parameters:
template<typename T>
class NumericBuffer {
static_assert(std::is_arithmetic_v<T>, "T must be arithmetic type");
static_assert(!std::is_same_v<T, bool>, "bool is not valid for numeric buffer");
std::vector<T> data_;
};
// Use in constexpr context:
template<size_t N>
struct FixedString {
static_assert(N > 0 && N <= 256, "FixedString length must be 1-256");
char data[N];
};Frequently Asked Questions
Does constexpr always run at compile time?
No — constexpr means "allowed at compile time if called with constant expressions." If called with runtime values, it executes at runtime normally. Use consteval when you want to guarantee compile-time execution and make runtime calls a hard error. Use if (std::is_constant_evaluated()) to write functions that behave differently in each context.
Why would constexpr slow down my build?
Compile-time computation uses the compiler as an interpreter. Complex constexpr computations — sorting large arrays, generating CRC tables — move computation to build time. This increases build time but produces a faster binary. For very complex constexpr (400+ line recursive templates), builds can become noticeably slower. Profile build time with -ftime-report (GCC) or clang -ftime-trace.
What is the "static initialization order fiasco" and how does constinit help?
In C++, the initialization order of global variables across translation units (.cpp files) is undefined. If global_b (in file2.cpp) depends on global_a (in file1.cpp), global_a might not be initialized yet when global_b's initializer runs. constinit prevents this by requiring the initializer to be a constant expression (evaluated at compile time) — no runtime initialization = no initialization order dependency.
Key Takeaway
Compile-time programming in C++ is not just a performance trick — it's a correctness mechanism. Compile-time validated format strings catch bugs before the program ships. Compile-time lookup tables eliminate an entire category of runtime computation. static_assert enforces struct layout contracts across platforms. consteval makes misuse a compile error rather than a runtime crash. Every time you push work left — from runtime to compile time — you get a faster binary and a safer system.
Read next: Type Traits & static_assert: Defensive Programming →
Part of the C++ Mastery Course — 30 modules from modern C++ basics to expert systems engineering.
