Moving Work to Compile Time in C++: constexpr, consteval, if constexpr and TMP

Key takeaways

Constants and lookup tables computed at runtime cost startup time and can hide errors until execution. The post shows which C++ tools force work to compile time, where each one stops, the trade-off between runtime savings and longer builds, and when template metaprogramming is still needed.

Introduction

C++ compile-time programming uses constexpr, consteval, and if constexpr to move work from runtime to compile time, so values such as table sizes, lookup tables or type-dependent branches are fixed before the program runs. The catch is that constexpr only permits compile-time evaluation, and pushing too much into the compiler slows builds. This post covers the differences between the three keywords, when a call actually runs at compile time, how the modern tools compare with template metaprogramming (TMP), and the common pitfalls.


constexpr Basics

constexpr Variables

#include <iostream>
constexpr int MAX_SIZE = 100;
constexpr double PI = 3.14159;
int main() {
    int arr[MAX_SIZE];  // Array size must be compile-time constant
    
    std::cout << PI << std::endl;
    
    return 0;
}

Key: constexpr variables can be used as array sizes and template arguments.

constexpr Functions

#include <iostream>
// Can execute at compile time or runtime
constexpr int square(int x) {
    return x * x;
}
int main() {
    constexpr int result = square(5);  // Compile-time
    int arr[result];  // OK: compile-time constant
    
    int x = 10;
    int result2 = square(x);  // Runtime (x is not constexpr)
    
    std::cout << result << ", " << result2 << std::endl;
    
    return 0;
}

Output:

25, 100

constexpr vs const

int readConfig();              // defined elsewhere, runs at startup

const int a = 10;              // const with a constant initializer: usable as a constant
const int b = readConfig();    // const, but the value is only known at runtime
constexpr int c = 10;          // constexpr: must be a constant, or it does not compile

int arr1[a];  // ✅ OK (a is a constant expression)
int arr2[b];  // ❌ Error (b is immutable, but not known at compile time)
int arr3[c];  // ✅ OK
// constexpr int d = readConfig();  // ❌ Error: initializer is not a constant expression

Key: a constexpr variable guarantees a compile-time value; const only guarantees immutability. An integral const initialized with a constant happens to be usable in constant expressions too (a rule inherited from C++98), which is why arr1 compiles. The difference is what happens when the initializer is not constant: const silently becomes a runtime value, while constexpr turns the mistake into a compile error at the declaration instead of a confusing error at the place of use.


constexpr Functions, Classes, if constexpr, and consteval

constexpr Functions

#include <iostream>
constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}
int main() {
    constexpr int fact5 = factorial(5);  // 120 (compile-time)
    
    std::cout << fact5 << std::endl;
    
    return 0;
}

Output:

120

Key: the call is evaluated at compile time here because the result initializes a constexpr variable, which requires a constant. Constant arguments alone are not enough: std::cout << factorial(5); is allowed to run at runtime, even though optimizers usually fold it anyway. Recursion depth is also limited during constant evaluation (GCC’s -fconstexpr-depth defaults to 512), so a recursive constexpr function that works at runtime for large inputs can fail at compile time with constexpr evaluation depth exceeds maximum.

constexpr Classes

#include <iostream>
class Point {
private:
    int x_, y_;
    
public:
    constexpr Point(int x, int y) : x_(x), y_(y) {}
    
    constexpr int getX() const { return x_; }
    constexpr int getY() const { return y_; }
    
    constexpr int distanceSquared() const {
        return x_ * x_ + y_ * y_;
    }
};
int main() {
    constexpr Point p(3, 4);
    constexpr int dist = p.distanceSquared();  // 25 (compile-time)
    
    int arr[dist];  // OK
    
    std::cout << dist << std::endl;
    
    return 0;
}

Output:

25

if constexpr (C++17)

#include <iostream>
#include <type_traits>
template<typename T>
auto getValue(T t) {
    if constexpr (std::is_pointer_v<T>) {
        return *t;  // Dereference if pointer
    } else {
        return t;   // Return as-is
    }
}
int main() {
    int x = 10;
    int* ptr = &x;
    
    std::cout << getValue(x) << std::endl;    // 10
    std::cout << getValue(ptr) << std::endl;  // 10
    
    return 0;
}

Output:

10
10

Key: if constexpr enables compile-time branching based on type traits, eliminating dead code paths.

consteval (C++20)

#include <iostream>
// Must execute at compile time only
consteval int sqr(int n) {
    return n * n;
}
int main() {
    constexpr int x = sqr(5);  // ✅ OK (compile-time)
    
    int y = 10;
    // int z = sqr(y);  // ❌ Error: runtime value
    
    std::cout << x << std::endl;
    
    return 0;
}

Output:

25

Key: consteval enforces compile-time-only execution, preventing runtime calls. Every call to a consteval function is an immediate invocation and must produce a constant on the spot, even inside another function. The catch is that a constexpr function cannot simply call a consteval one with its own parameters, because inside a constexpr function the parameters are not constants; you get error: 'n' is not a constant expression. C++23 (P2564) relaxes this for constexpr function templates and lambdas, which are promoted to immediate functions automatically, but a plain constexpr function still cannot do it, so consteval tends to spread up the call chain. Use it for values that are genuinely compile-time only, such as format-string checks or hashing string literals, not as a general “make it faster” switch.


String Hashing, Array Generation, and TMP at Compile Time

Compile-Time String Hashing

#include <iostream>
constexpr unsigned int hash(const char* str) {
    unsigned int hash = 5381;
    while (*str) {
        hash = ((hash << 5) + hash) + (*str++);
    }
    return hash;
}
int main() {
    constexpr unsigned int startHash = hash("start");
    constexpr unsigned int stopHash = hash("stop");
    
    const char* command = "start";
    
    switch (hash(command)) {
        case startHash:
            std::cout << "Start" << std::endl;
            break;
        case stopHash:
            std::cout << "Stop" << std::endl;
            break;
        default:
            std::cout << "Unknown" << std::endl;
    }
    
    return 0;
}

Output:

Start

Key: compile-time hashing lets a switch dispatch on strings: the case labels are hashed by the compiler, and only hash(command) runs at runtime. Two caveats matter in real use. First, a hash collision between two case strings is a compile error (duplicate case value), which is good, but a collision between a case string and an arbitrary input string is not detected at all: an unknown command that happens to hash to startHash runs the start branch. Production code compares the string after the hash matches. Second, the hash is computed on unsigned int so that overflow wraps; with a signed int, overflow during constant evaluation is a compile error (overflow in constant expression) rather than silent wrapping.

Compile-Time Array Generation

#include <array>
#include <iostream>
template<size_t N>
constexpr auto generateFibonacci() {
    std::array<int, N> result{};
    if (N > 0) result[0] = 0;
    if (N > 1) result[1] = 1;
    
    for (size_t i = 2; i < N; ++i) {
        result[i] = result[i - 1] + result[i - 2];
    }
    
    return result;
}
int main() {
    constexpr auto fib = generateFibonacci<10>();
    
    for (int x : fib) {
        std::cout << x << " ";  // 0 1 1 2 3 5 8 13 21 34
    }
    std::cout << std::endl;
    
    return 0;
}

Output:

0 1 1 2 3 5 8 13 21 34

Key: Compile-time array generation creates lookup tables with zero runtime cost.

Template Metaprogramming (TMP)

#include <iostream>
// Recursive template
template<int N>
struct Factorial {
    static constexpr int value = N * Factorial<N - 1>::value;
};
template<>
struct Factorial<0> {
    static constexpr int value = 1;
};
int main() {
    std::cout << Factorial<5>::value << std::endl;  // 120 (compile-time)
    
    return 0;
}

Output:

120

Key: TMP uses template recursion for compile-time computation. Prefer constexpr functions for readability.


Performance: Where the Cost Goes

Fibonacci at runtime vs compile time

#include <chrono>
#include <iostream>
// Runtime
int runtimeFib(int n) {
    if (n <= 1) return n;
    return runtimeFib(n - 1) + runtimeFib(n - 2);
}
// Compile-time capable (iterative, see Issue 3)
constexpr int compiletimeFib(int n) {
    int a = 0, b = 1;
    for (int i = 0; i < n; ++i) { int t = a + b; a = b; b = t; }
    return a;
}
int main() {
    auto start = std::chrono::steady_clock::now();
    int r = runtimeFib(40);
    auto end = std::chrono::steady_clock::now();
    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    std::cout << "Runtime: " << r << " in " << ms << "ms\n";

    constexpr int c = compiletimeFib(40);   // the binary just contains 102334155
    std::cout << "Compile-time: " << c << '\n';
}

Run it on your own machine: the recursive runtime version makes hundreds of millions of calls for n = 40 and takes a noticeable fraction of a second, while the constexpr value costs nothing at runtime, because the compiled program only contains the constant 102334155. The comparison is a little unfair, though. The runtime version is slow because of the exponential algorithm, not because it runs at runtime; the iterative loop would finish in nanoseconds there too.

The interesting lesson is on the compile side. If you make the recursive version constexpr and write constexpr int c = recursiveFib(40);, it does not compile on GCC: constant evaluation has an operation budget (-fconstexpr-ops-limit, default 2^25), and you get constexpr evaluation operation count exceeds limit. Clang has a similar -fconstexpr-steps limit. The compiler’s interpreter is much slower than compiled machine code, so an algorithm that is merely slow at runtime can be impossible at compile time. The first time I pushed a table-building loop into constexpr, this limit was what I hit, not build time; raising the limit works, but switching to an iterative algorithm is almost always the better fix.


Lookup Tables, Type Checks, Sorting, and Bit Tricks

Pattern 1: Lookup Table Generation

#include <array>
#include <iostream>
#include <cmath>
constexpr auto generateSinTable() {
    constexpr size_t SIZE = 360;
    std::array<double, SIZE> table{};
    
    constexpr double PI = 3.14159265358979323846;
    
    for (size_t i = 0; i < SIZE; ++i) {
        double radians = i * PI / 180.0;
        // In real code: table[i] = std::sin(radians);
        // (std::sin is not constexpr in C++17, but is in C++26)
        table[i] = radians;
    }
    
    return table;
}
int main() {
    constexpr auto sinTable = generateSinTable();
    
    std::cout << sinTable[45] << std::endl;  // 0.785398 (45 degrees)
    
    return 0;
}

Key: Pre-compute trigonometric tables at compile time for real-time graphics/physics.

Pattern 2: Type Checking

#include <iostream>
#include <type_traits>
template<typename T>
constexpr bool isNumeric() {
    if constexpr (std::is_integral_v<T> || std::is_floating_point_v<T>) {
        return true;
    } else {
        return false;
    }
}
template<typename T>
void process(T value) {
    if constexpr (isNumeric<T>()) {
        std::cout << "Number: " << value << std::endl;
    } else {
        std::cout << "String: " << value << std::endl;
    }
}
int main() {
    process(42);       // Number: 42
    process("hello");  // String: hello
    
    return 0;
}

Output:

Number: 42
String: hello

Pattern 3: Compile-Time Sorting

#include <algorithm>
#include <array>
#include <iostream>
constexpr void bubbleSort(int* arr, int n) {
    for (int i = 0; i < n - 1; ++i) {
        for (int j = 0; j < n - i - 1; ++j) {
            if (arr[j] > arr[j + 1]) {
                int temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
            }
        }
    }
}
constexpr auto getSortedArray() {
    std::array<int, 5> arr = {5, 2, 8, 1, 9};
    bubbleSort(arr.data(), arr.size());
    return arr;
}
int main() {
    constexpr auto sorted = getSortedArray();
    
    for (int x : sorted) {
        std::cout << x << " ";  // 1 2 5 8 9
    }
    std::cout << std::endl;
    
    return 0;
}

Output:

1 2 5 8 9

Pattern 4: Bit Manipulation

#include <iostream>
constexpr unsigned int reverseBits(unsigned int n) {
    unsigned int result = 0;
    for (int i = 0; i < 32; ++i) {
        result <<= 1;
        result |= (n & 1);
        n >>= 1;
    }
    return result;
}
int main() {
    constexpr unsigned int reversed = reverseBits(0b10110000);
    
    std::cout << std::hex << reversed << std::endl;
    
    return 0;
}

Key: Compile-time bit manipulation for embedded systems and cryptography.


Why a constexpr Call Falls Back to Runtime or Fails

Issue 1: constexpr Constraints

// ❌ Static variables not allowed (pre-C++23)
constexpr int bad() {
    static int x = 0;  // Error
    return x++;
}
// ❌ Dynamic allocation not allowed (C++17)
constexpr int bad2() {
    int* p = new int(10);  // Error
    return *p;
}
// ✅ OK
constexpr int good(int x) {
    return x * 2;
}

Key: constexpr functions have restrictions, and they loosen with every standard. C++23 allows a static local in a constexpr function, but only on paths that are never constant-evaluated, so bad() compiles yet constexpr int v = bad(); still fails. C++20 allows new in constant evaluation, but only if the memory is freed before the evaluation ends; bad2() leaks, so it fails with a message like 'bad2()' is not a constant expression because allocated storage has not been deallocated. This “transient allocation” rule is why a constexpr std::vector can be used inside a compile-time computation but cannot be stored in a constexpr variable; copy the result into a std::array before returning.

Issue 2: Runtime Values

#include <iostream>
int main() {
    int x;
    std::cin >> x;
    
    // ❌ Error: runtime value
    constexpr int y = x * 2;
    
    // ✅ OK: runtime variable
    int y2 = x * 2;
    
    return 0;
}

Key: constexpr requires compile-time known values.

Issue 3: Complex Computation

// ❌ Slow compile time
constexpr int slowFib(int n) {
    if (n <= 1) return n;
    return slowFib(n - 1) + slowFib(n - 2);
}
constexpr int x = slowFib(50);  // exceeds the constexpr operation limit: compile error
// ✅ Memoization
constexpr auto fastFib() {
    std::array<long long, 50> result{};   // fib(49) = 7778742049 does not fit in int
    result[0] = 0;
    result[1] = 1;
    for (int i = 2; i < 50; ++i) {
        result[i] = result[i - 1] + result[i - 2];
    }
    return result;
}
constexpr auto fibTable = fastFib();
constexpr long long y = fibTable[49];  // Fast

Key: Use iterative algorithms or memoization to avoid exponential compile-time complexity. Note the element type: with std::array<int, 50>, fib(47) overflows int. At runtime that is silent undefined behaviour; during constant evaluation the compiler must diagnose it and stops with overflow in constant expression. This is one of the underrated benefits of compile-time evaluation: undefined behaviour that tests would miss at runtime becomes a build error.

Issue 4: constexpr vs consteval Confusion

// constexpr: compile-time or runtime
constexpr int square(int n) {
    return n * n;
}
int x = 10;
int y = square(x);  // ✅ Runs at runtime
// consteval: compile-time only
consteval int sqr(int n) {
    return n * n;
}
constexpr int z = sqr(5);  // ✅ OK
// int w = sqr(x);  // ❌ Error: runtime value

Key: Use consteval to enforce compile-time-only intent.


Recursive Templates and When constexpr Replaces Them

Recursive Templates

#include <iostream>
// Recursive template
template<int N>
struct Factorial {
    static constexpr int value = N * Factorial<N - 1>::value;
};
template<>
struct Factorial<0> {
    static constexpr int value = 1;
};
int main() {
    std::cout << Factorial<5>::value << std::endl;  // 120 (compile-time)
    
    return 0;
}

Output:

120

Key: TMP uses template specialization for compile-time recursion. Prefer constexpr functions for readability.

TMP vs constexpr

// TMP (verbose)
template<int N>
struct Factorial {
    static constexpr int value = N * Factorial<N - 1>::value;
};
// constexpr (readable)
constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}

Recommendation: Use constexpr functions for most compile-time computation; reserve TMP for type-level programming.

The reason TMP survives is that constexpr functions compute values, not types. A function cannot return int for one input and std::string for another, and it cannot take a list of types as a parameter. Selecting a type based on a condition (std::conditional_t), stripping qualifiers (std::remove_cvref_t), walking a type list to find an index, or detecting whether a type has a member are all still template work, now usually written with alias templates, if constexpr and C++20 concepts rather than recursive structs. Value computation that used to be written as Factorial<N>::value has no reason to stay in that form: every Factorial<N> is a separate class instantiation that the compiler keeps in memory, while factorial(n) is just an interpreted function call.


Configuration Tables, Type Dispatch, and Compile-Time Validation

Use Case 1: Configuration Tables

#include <array>
constexpr auto generateConfigTable() {
    std::array<int, 256> table{};
    for (int i = 0; i < 256; ++i) {
        table[i] = i * 2;  // Example: multiply by 2
    }
    return table;
}
constexpr auto configTable = generateConfigTable();
int main() {
    int value = configTable[42];  // Instant lookup
}

Key: Pre-compute configuration tables for embedded systems.

Use Case 2: Type Dispatch

#include <iostream>
#include <type_traits>
template<typename T>
void process(T value) {
    if constexpr (std::is_integral_v<T>) {
        std::cout << "Integer: " << value << std::endl;
    } else if constexpr (std::is_floating_point_v<T>) {
        std::cout << "Float: " << value << std::endl;
    } else {
        std::cout << "Other: " << value << std::endl;
    }
}
int main() {
    process(42);       // Integer: 42
    process(3.14);     // Float: 3.14
    process("hello");  // Other: hello
    
    return 0;
}

Output:

Integer: 42
Float: 3.14
Other: hello

Use Case 3: Compile-Time Validation

#include <array>
template<size_t N>
constexpr bool isPowerOfTwo() {
    return N > 0 && (N & (N - 1)) == 0;
}
template<size_t N>
class Buffer {
    static_assert(isPowerOfTwo<N>(), "Buffer size must be power of 2");
    std::array<char, N> data;
};
int main() {
    Buffer<256> buf1;  // ✅ OK
    // Buffer<100> buf2;  // ❌ Error: not power of 2
}

Key: Use static_assert with constexpr functions for compile-time validation.


Build-Time Cost

A small constexpr call such as factorial(10) adds no measurable build time. Cost appears with large tables and heavy loops, and it has a property that is easy to overlook: it is paid in every translation unit that evaluates the expression. A constexpr auto table = generateLargeTable<10000>(); in a header included by 200 .cpp files is computed 200 times per full build. Two things keep this under control. Put large generated tables in one .cpp and expose them through a declaration, so only that file pays for them. And measure instead of guessing: Clang’s -ftime-trace writes a per-file JSON timeline (viewable in chrome://tracing or Perfetto) that shows how long constant evaluation and template instantiation took, and MSVC has /d1reportTime and Build Insights for the same purpose.

A table is also not automatically faster than computing the value. A 256-entry table of int is 1 KB and fits in L1 cache; a table of tens of thousands of doubles competes with your working set, and a cache miss can cost more than recomputing a cheap formula. Compile-time tables win when the computation is expensive and the table is small.


Rules I Follow for Compile-Time Code

Use constexpr for Pure Functions

// ✅ Pure function: good constexpr candidate
constexpr int add(int a, int b) {
    return a + b;
}
// ❌ Side effects: not constexpr
int addAndLog(int a, int b) {
    std::cout << "Adding" << std::endl;  // I/O not allowed
    return a + b;
}

Prefer constexpr Over TMP

// ❌ TMP (verbose)
template<int N>
struct Factorial {
    static constexpr int value = N * Factorial<N - 1>::value;
};
// ✅ constexpr (readable)
constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}

Use consteval for Compile-Time-Only Intent

// ✅ Enforce compile-time
consteval int configValue(int id) {
    return id * 100;
}
constexpr int x = configValue(5);  // OK
// int y = configValue(runtimeValue);  // Error

Avoid Deep Recursion

// ❌ Deep recursion (slow compile)
constexpr int fib(int n) {
    return n <= 1 ? n : fib(n-1) + fib(n-2);
}
// ✅ Iterative (fast compile)
constexpr int fib(int n) {
    int a = 0, b = 1;
    for (int i = 0; i < n; ++i) {
        int temp = a + b;
        a = b;
        b = temp;
    }
    return a;
}

Use static_assert for Documentation

template<typename T>
void process(T value) {
    static_assert(std::is_arithmetic_v<T>, "T must be numeric");
    // ...
}

Choosing constexpr, consteval, or TMP

  1. constexpr
    • Compile-time or runtime execution
    • Variables, functions, classes
    • Array sizes, template arguments
  2. consteval (C++20)
    • Compile-time-only execution
    • Immediate functions
    • No runtime values allowed
  3. if constexpr (C++17)
    • Compile-time branching
    • Type-based conditional compilation
    • Replaces many tag-dispatch and specialization tricks
  4. Performance
    • Compile-time: zero runtime overhead
    • Compile time may increase
    • Use memoization for complex computation

Selection Guide

ScenarioRecommendationReason
Compile-time constantconstexprArray sizes, etc.
Compile-time-onlyconstevalClear intent
Type-based branchingif constexprConditional compilation
Runtime also neededconstexprFlexibility

Cheat Sheet

// constexpr variable
constexpr int MAX_SIZE = 100;
// constexpr function
constexpr int square(int x) {
    return x * x;
}
// constexpr class
class Point {
public:
    constexpr Point(int x, int y) : x_(x), y_(y) {}
    constexpr int getX() const { return x_; }
private:
    int x_, y_;
};
// if constexpr
template<typename T>
auto getValue(T t) {
    if constexpr (std::is_pointer_v<T>) {
        return *t;
    } else {
        return t;
    }
}
// consteval
consteval int sqr(int n) {
    return n * n;
}


Frequently Asked Questions (FAQ)

Q. I marked a function constexpr, so why does it still run at runtime?

A. constexpr only permits compile-time evaluation; it is guaranteed only when the result is used in a context that requires a constant expression, such as initializing a constexpr variable, a template argument, an array bound or a static_assert. Called with runtime arguments, or assigned to an ordinary variable, it may be evaluated at runtime. Store the result in a constexpr variable, or make the function consteval (C++20) if it must never run at runtime.