C++Performance

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

TT
TopicTrick Team
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

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:

cpp
#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 calls

Rules for constexpr functions:

  • No goto, thread-local storage, or I/O (printf, fopen, etc.)
  • No undefined behavior — the compiler catches it
  • Can call only constexpr functions
  • Local variables of non-literal types require C++14+

constexpr Classes and Objects (C++14/20)

cpp
// 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:

cpp
#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:

cpp
// 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 guaranteed

Compile-Time Lookup Tables with IIFE

Generate lookup tables entirely at compile time — binary contains pre-computed data:

cpp
#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:

cpp
#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 smart

static_assert: Compile-Time Validation

cpp
// 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.