constexpr Functions: Compile-Time Computation

A constexpr function in C++ is a function that can be evaluated at compile time when called with constant expressions, producing results embedded directly into the compiled program. When called with non-constant arguments, it behaves as a regular runtime function. Declared with the constexpr keyword, these functions must have a body that the compiler can evaluate: no runtime I/O, no dynamic allocation (before C++20), no undefined behavior. C++20 dramatically expanded what is allowed in constexpr context — including std::vector, std::string, try/catch, and virtual functions.

Introduction

Every program has two phases: compile time and runtime. Traditionally, all computation happens at runtime — the CPU executes instructions when the program runs. But much of what programs compute is actually known at compile time: mathematical constants, lookup tables, string hashes, configuration structures, CRC tables, prime sieves. Computing these at runtime wastes CPU cycles on work that could have been done once, by the compiler, with the result baked into the binary.

constexpr is C++’s mechanism for moving computation from runtime to compile time. A constexpr function or variable is evaluated by the compiler itself — not the CPU — and its result is embedded in the compiled code as a literal. From the program’s perspective, reading a constexpr computed value is like reading the constant 42: there is no instruction to execute, just a value already present.

The benefits go beyond raw performance. Compile-time computation enables things that are impossible or unsafe at runtime: constexpr values can be used as template arguments, array sizes, and switch case labels. Compile-time errors are better than runtime errors — if a constexpr function contains undefined behavior or violates a precondition, the compiler reports an error rather than the program crashing in production.

C++11 introduced constexpr with severe restrictions — only a single return statement allowed. C++14 relaxed this dramatically, allowing loops, local variables, and most control flow. C++17 made if constexpr work with constexpr functions. C++20 completed the transformation, allowing dynamic allocation, virtual functions, try/catch, std::vector, and std::string in constexpr context — making nearly any computation expressible at compile time.

This article teaches constexpr from first principles through C++20’s expanded capabilities, with practical examples showing when and how to move computation to compile time.

The Basics: constexpr Variables and Functions

#include <iostream>
using namespace std;

// constexpr variable: evaluated at compile time
constexpr double PI      = 3.14159265358979;
constexpr int    MAX_BUF = 1024;
constexpr bool   DEBUG   = false;

// constexpr function: can be evaluated at compile time
constexpr int square(int x) {
    return x * x;
}

constexpr int factorial(int n) {
    // C++14+: loops and local variables are allowed
    int result = 1;
    for (int i = 2; i <= n; i++) {
        result *= i;
    }
    return result;
}

constexpr double circleArea(double radius) {
    return PI * radius * radius;
}

int main() {
    // Compile-time evaluation: result is a compile-time constant
    constexpr int s = square(7);         // Computed by compiler: s = 49
    constexpr int f = factorial(10);     // Computed by compiler: f = 3628800
    constexpr double area = circleArea(5.0); // Computed by compiler

    cout << "7 squared: "        << s    << endl;
    cout << "10 factorial: "     << f    << endl;
    cout << "Area of r=5: "      << area << endl;

    // constexpr values as template arguments (requires compile-time constant)
    array<int, square(8)> arr;  // array size = 64, computed at compile time
    cout << "Array size: " << arr.size() << endl;

    // constexpr values as switch case labels
    constexpr int CODE = 42;
    switch (CODE) {
        case square(6):   cout << "CODE is 36" << endl; break;
        case factorial(3): cout << "CODE is 6" << endl;  break;
        case 42:          cout << "CODE is 42" << endl; break;
    }

    // Runtime evaluation: same function, runtime argument
    int runtimeValue;
    cin >> runtimeValue;  // Read at runtime
    int runtimeSquare = square(runtimeValue);  // Evaluated at runtime
    cout << runtimeValue << " squared = " << runtimeSquare << endl;

    // Demonstrating compile-time vs runtime distinction
    constexpr int compile_result = square(10);  // Must be compile-time
    int runtime_n = 10;
    int runtime_result = square(runtime_n);     // Runtime — n is not constexpr

    cout << "compile_result = " << compile_result << endl;
    cout << "runtime_result = " << runtime_result << endl;

    return 0;
}

Sample output (with input 5):

7 squared: 49
10 factorial: 3628800
Area of r=5: 78.5398
Array size: 64
CODE is 42
5 squared = 25
compile_result = 100
runtime_result = 100

Step-by-step explanation:

  1. constexpr int s = square(7) forces compile-time evaluation because the result is stored in a constexpr variable. The compiler must be able to evaluate square(7) during compilation — if it cannot (e.g., the function reads from stdin), it produces a compile error.
  2. array<int, square(8)> uses the result as a template non-type argument. Template arguments must be compile-time constants — this is only possible because square(8) is a constant expression. With a non-constexpr function, this would be a compile error.
  3. int runtimeSquare = square(runtimeValue) evaluates square at runtime because runtimeValue is not a compile-time constant (it was read from stdin). The same constexpr function executes both paths — compile-time when given constant arguments, runtime otherwise.
  4. constexpr variables (PI, MAX_BUF) are like #define constants but type-safe, scoped, and debuggable. Unlike #define, they participate in the type system and cannot cause macro-expansion surprises.
  5. The distinction: constexpr int x = f() requires compile-time evaluation. int x = f() allows but does not require compile-time evaluation — the compiler may or may not optimize it. Only constexpr guarantees compile-time execution.

C++14 Relaxations: Loops, Branches, and Local Variables

C++11 constexpr functions were restricted to a single return statement — effectively only recursive expressions were possible. C++14 removed most restrictions, enabling natural imperative-style code.

#include <iostream>
#include <array>
using namespace std;

// Binary search at compile time (C++14 style)
constexpr int binarySearch(const int* arr, int size, int target) {
    int lo = 0, hi = size - 1;
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;
        if      (arr[mid] == target) return mid;
        else if (arr[mid] <  target) lo = mid + 1;
        else                          hi = mid - 1;
    }
    return -1;  // Not found
}

// Compile-time string length
constexpr size_t strLen(const char* s) {
    size_t len = 0;
    while (s[len] != '\0') ++len;
    return len;
}

// Compile-time power function with integer exponent
constexpr long long power(long long base, int exp) {
    if (exp < 0)  return 0;  // Error case
    if (exp == 0) return 1;
    long long result = 1;
    long long b = base;
    int e = exp;
    while (e > 0) {
        if (e & 1) result *= b;   // Exponentiation by squaring
        b *= b;
        e >>= 1;
    }
    return result;
}

// Compile-time GCD (Euclidean algorithm)
constexpr int gcd(int a, int b) {
    while (b != 0) {
        int t = b;
        b = a % b;
        a = t;
    }
    return a;
}

// Compile-time prime check
constexpr bool isPrime(int n) {
    if (n < 2)  return false;
    if (n == 2) return true;
    if (n % 2 == 0) return false;
    for (int i = 3; i * i <= n; i += 2) {
        if (n % i == 0) return false;
    }
    return true;
}

// Compile-time FNV-1a hash for string literals
constexpr uint32_t fnv1a(const char* s) {
    uint32_t hash = 2166136261u;
    while (*s) {
        hash ^= static_cast<uint8_t>(*s++);
        hash *= 16777619u;
    }
    return hash;
}

int main() {
    // Binary search: both array and search are compile-time
    constexpr int sortedData[] = {2, 5, 8, 12, 16, 23, 38, 56, 72, 91};
    constexpr int idx = binarySearch(sortedData, 10, 23);
    cout << "Index of 23: " << idx << endl;  // 5

    constexpr int notFound = binarySearch(sortedData, 10, 99);
    cout << "Index of 99: " << notFound << endl;  // -1

    // String operations at compile time
    constexpr size_t len = strLen("Hello, World!");
    cout << "String length: " << len << endl;  // 13
    static_assert(len == 13, "Length should be 13");

    // Power computations
    constexpr long long two20   = power(2, 20);   // 1048576
    constexpr long long ten6    = power(10, 6);   // 1000000
    cout << "2^20 = "  << two20 << endl;
    cout << "10^6 = "  << ten6  << endl;

    // GCD
    constexpr int g = gcd(48, 18);  // 6
    cout << "gcd(48, 18) = " << g << endl;

    // Prime detection as template arguments
    constexpr bool p17 = isPrime(17);  // true
    constexpr bool p18 = isPrime(18);  // false
    cout << "17 is prime: " << p17 << endl;
    cout << "18 is prime: " << p18 << endl;

    // String hashing at compile time — usable in switch statements
    constexpr uint32_t HASH_START = fnv1a("start");
    constexpr uint32_t HASH_STOP  = fnv1a("stop");
    constexpr uint32_t HASH_RESET = fnv1a("reset");

    string command = "stop";  // Runtime string
    uint32_t commandHash = fnv1a(command.c_str());  // Runtime hash

    switch (commandHash) {
        case HASH_START: cout << "Executing: start" << endl; break;
        case HASH_STOP:  cout << "Executing: stop"  << endl; break;
        case HASH_RESET: cout << "Executing: reset" << endl; break;
        default:         cout << "Unknown command"  << endl; break;
    }

    return 0;
}

Output:

Index of 23: 5
Index of 99: -1
String length: 13
2^20 = 1048576
10^6 = 1000000
gcd(48, 18) = 6
17 is prime: 1
18 is prime: 0
Executing: stop

Step-by-step explanation:

  1. binarySearch uses a while loop — impossible in C++11 constexpr, completely natural in C++14+. The array and size are compile-time constants (passed as constexpr values), so the entire search happens at compile time.
  2. strLen("Hello, World!") iterates a null-terminated string at compile time. String literals are compile-time constants — their characters are known to the compiler. The result 13 is embedded in the binary as a literal.
  3. power(2, 20) uses exponentiation by squaring — a standard algorithm, implemented with a while loop and bit operations. The compiler executes this algorithm during compilation and embeds 1048576 in the binary.
  4. The FNV-1a hash trick: constexpr hash values of string literals can be used as switch case labels. At runtime, you hash the input string and compare against the precomputed hashes. This converts string dispatch (if (cmd == "start")) into integer comparison, which is faster for many commands.
  5. static_assert(len == 13) verifies compile-time results — a compile-time assertion that fires if the condition is false. This is a zero-runtime-cost correctness check: it cannot possibly slow down the program because it does not exist at runtime.

Compile-Time Lookup Tables

One of the most practical uses of constexpr is generating lookup tables — large precomputed arrays that would be expensive to compute at runtime.

#include <iostream>
#include <array>
using namespace std;

// Generate a complete CRC8 lookup table at compile time
constexpr uint8_t crc8_byte(uint8_t byte) {
    for (int i = 0; i < 8; i++) {
        if (byte & 0x80)
            byte = (byte << 1) ^ 0x07;  // CRC-8/SMBUS polynomial
        else
            byte <<= 1;
    }
    return byte;
}

constexpr array<uint8_t, 256> makeCRC8Table() {
    array<uint8_t, 256> table{};
    for (int i = 0; i < 256; i++) {
        table[i] = crc8_byte(static_cast<uint8_t>(i));
    }
    return table;
}

constexpr auto CRC8_TABLE = makeCRC8Table();

// Compute CRC8 using the precomputed table
uint8_t crc8(const uint8_t* data, size_t len) {
    uint8_t crc = 0;
    for (size_t i = 0; i < len; i++) {
        crc = CRC8_TABLE[crc ^ data[i]];
    }
    return crc;
}

// Sine approximation table (fixed-point, 256 entries for 0-360 degrees)
constexpr array<int16_t, 256> makeSineTable() {
    array<int16_t, 256> table{};
    // We use the Taylor series for sin since <cmath> may not be constexpr
    // sin(x) ≈ x - x³/6 + x⁵/120 - x⁷/5040
    constexpr double PI2 = 6.28318530718;
    for (int i = 0; i < 256; i++) {
        double angle = (i * PI2) / 256.0;
        // Taylor series (5 terms for reasonable accuracy)
        double x = angle;
        double s = x;
        double term = x;
        for (int n = 1; n <= 5; n++) {
            term *= -(x * x) / ((2*n) * (2*n + 1));
            s += term;
        }
        // Scale to int16_t range: -32767 to 32767
        table[i] = static_cast<int16_t>(s * 32767.0);
    }
    return table;
}

constexpr auto SINE_TABLE = makeSineTable();

// Perfect powers table: is N a perfect square, cube, etc.?
constexpr array<bool, 100> makePerfectSquares() {
    array<bool, 100> table{};
    for (int i = 0; i < 10; i++) {
        table[i * i] = true;
    }
    return table;
}

constexpr auto PERFECT_SQUARES = makePerfectSquares();

// Fibonacci lookup table
constexpr array<uint64_t, 93> makeFibTable() {
    array<uint64_t, 93> table{};
    table[0] = 0; table[1] = 1;
    for (int i = 2; i < 93; i++) {
        table[i] = table[i-1] + table[i-2];
    }
    return table;
}

constexpr auto FIB_TABLE = makeFibTable();

int main() {
    cout << "=== CRC8 table (first 8 entries) ===" << endl;
    for (int i = 0; i < 8; i++) {
        cout << "  CRC8_TABLE[" << i << "] = "
             << static_cast<int>(CRC8_TABLE[i]) << endl;
    }

    // Use the CRC table to verify data integrity
    const uint8_t data[] = {0x01, 0x02, 0x03, 0x04, 0x05};
    uint8_t checksum = crc8(data, sizeof(data));
    cout << "CRC8 of {1,2,3,4,5} = 0x"
         << hex << static_cast<int>(checksum) << dec << endl;

    cout << "\n=== Sine table samples ===" << endl;
    // Index 0 = 0°, 64 = 90°, 128 = 180°, 192 = 270°
    cout << "sin(0°)   = " << SINE_TABLE[0]   / 32767.0 << endl;  // ≈ 0
    cout << "sin(90°)  = " << SINE_TABLE[64]  / 32767.0 << endl;  // ≈ 1
    cout << "sin(180°) = " << SINE_TABLE[128] / 32767.0 << endl;  // ≈ 0
    cout << "sin(270°) = " << SINE_TABLE[192] / 32767.0 << endl;  // ≈ -1

    cout << "\n=== Perfect squares under 100 ===" << endl;
    cout << "Perfect squares: ";
    for (int i = 0; i < 100; i++) {
        if (PERFECT_SQUARES[i]) cout << i << " ";
    }
    cout << endl;

    cout << "\n=== Fibonacci numbers ===" << endl;
    cout << "First 10 Fibonacci: ";
    for (int i = 0; i < 10; i++) cout << FIB_TABLE[i] << " ";
    cout << endl;
    cout << "Fib(50) = " << FIB_TABLE[50] << endl;
    cout << "Fib(92) = " << FIB_TABLE[92] << endl;

    // These tables exist entirely in the .rodata section —
    // zero runtime computation needed
    static_assert(FIB_TABLE[10] == 55, "Fibonacci check");
    static_assert(PERFECT_SQUARES[49], "49 is a perfect square");

    return 0;
}

Output:

=== CRC8 table (first 8 entries) ===
  CRC8_TABLE[0] = 0
  CRC8_TABLE[1] = 7
  CRC8_TABLE[2] = 14
  CRC8_TABLE[3] = 9
  CRC8_TABLE[4] = 28
  CRC8_TABLE[5] = 27
  CRC8_TABLE[6] = 18
  CRC8_TABLE[7] = 21
CRC8 of {1,2,3,4,5} = 0x77

=== Sine table samples ===
sin(0°)   = 0
sin(90°)  = 1
sin(180°) = -0.000479
sin(270°) = -1

=== Perfect squares under 100 ===
Perfect squares: 0 1 4 9 16 25 36 49 64 81 

=== Fibonacci numbers ===
First 10 Fibonacci: 0 1 1 2 3 5 8 13 21 34 
Fib(50) = 12586269025
Fib(92) = 7540113804746346429

Step-by-step explanation:

  1. constexpr auto CRC8_TABLE = makeCRC8Table() generates the entire 256-entry CRC8 lookup table at compile time. The resulting array is placed in the binary’s read-only data section (.rodata). At runtime, crc8() simply indexes into this precomputed table — no computation needed.
  2. makeSineTable() returns a constexpr array<int16_t, 256> computed using a Taylor series. This avoids the need for <cmath>‘s sin() (which may not be constexpr in all implementations) while providing a reasonably accurate fixed-point sine table for embedded or real-time use.
  3. makeFibTable() memoizes all Fibonacci numbers up to the maximum that fits in uint64_t (the 93rd). Without this table, computing FIB_TABLE[92] at runtime would require iteration; with it, it’s a single array access — O(1) vs O(n).
  4. static_assert(FIB_TABLE[10] == 55) verifies the table is correct at compile time. This is a zero-cost regression test embedded in the code — the compiler checks it every build.
  5. All four tables compile to static data in the binary. The program starts with all values precomputed — no initialization loop, no first-use overhead. This is particularly valuable for embedded systems where startup time matters.

consteval and constinit (C++20)

C++20 introduced two new keywords that give finer control over compile-time evaluation:

#include <iostream>
#include <stdexcept>
using namespace std;

// consteval: MUST be evaluated at compile time — always
// If called with non-constant arguments, it's a compile ERROR
consteval int mustBeCompileTime(int x) {
    return x * x + 1;
}

// constexpr: CAN be compile-time, may be runtime
constexpr int canBeEither(int x) {
    return x * x + 1;
}

// consteval is useful for:
// 1. Template-argument-like values from function syntax
consteval int makeFlag(int bit) {
    if (bit < 0 || bit > 31) throw out_of_range("bit out of range");
    return 1 << bit;
}

// 2. Compile-time ID generation
consteval size_t typeId(const char* name) {
    size_t hash = 14695981039346656037ull;
    while (*name) {
        hash ^= static_cast<uint8_t>(*name++);
        hash *= 1099511628211ull;
    }
    return hash;
}

// constinit: variable is guaranteed to be zero-initialized at compile time
// (not a constant, but guaranteed constant initialization — no static init order fiasco)
constinit int globalCounter = 0;          // OK: constant initialization
constinit double gravity = 9.81;          // OK: constant initialization

// constinit int bad = rand();            // ERROR: rand() not constexpr

struct Config {
    constinit static int maxConnections;  // Declared constinit — no fiasco
};
constinit int Config::maxConnections = 100;

// Combining consteval with if consteval (C++23 preview concept)
// For C++20: use a helper template

constexpr int sqrt_approx(int n) {
    // This works at both compile time and runtime
    int result = 0;
    while ((result + 1) * (result + 1) <= n) result++;
    return result;
}

int main() {
    cout << "=== consteval ===" << endl;

    // OK: compile-time constant argument
    constexpr int a = mustBeCompileTime(7);   // Evaluated at compile time
    cout << "mustBeCompileTime(7) = " << a << endl;

    // OK: used directly in constexpr context
    static_assert(mustBeCompileTime(5) == 26, "5*5+1=26");

    // COMPILE ERROR (commented out — would not compile):
    // int x = 5;
    // int b = mustBeCompileTime(x);  // ERROR: x is not constexpr

    // canBeEither works at both:
    constexpr int c = canBeEither(7);  // Compile time
    int y = 7;
    int d = canBeEither(y);            // Runtime

    cout << "canBeEither(7) compile: " << c << endl;
    cout << "canBeEither(7) runtime: " << d << endl;

    cout << "\n=== makeFlag (bit flags) ===" << endl;
    constexpr int FLAG_READ    = makeFlag(0);  // 1
    constexpr int FLAG_WRITE   = makeFlag(1);  // 2
    constexpr int FLAG_EXECUTE = makeFlag(2);  // 4
    cout << "READ:    " << FLAG_READ    << endl;
    cout << "WRITE:   " << FLAG_WRITE   << endl;
    cout << "EXECUTE: " << FLAG_EXECUTE << endl;

    // COMPILE ERROR: bit out of range:
    // constexpr int badFlag = makeFlag(40);  // throws at compile time → error

    cout << "\n=== typeId for compile-time type tags ===" << endl;
    constexpr size_t intId    = typeId("int");
    constexpr size_t stringId = typeId("std::string");
    constexpr size_t vecId    = typeId("std::vector<int>");
    cout << "typeId(\"int\")             = " << intId    << endl;
    cout << "typeId(\"std::string\")     = " << stringId << endl;
    cout << "typeId(\"std::vector<int>\")= " << vecId    << endl;
    cout << "All unique: "
         << (intId != stringId && stringId != vecId ? "yes" : "no") << endl;

    cout << "\n=== constinit globals ===" << endl;
    cout << "globalCounter: " << globalCounter << endl;
    cout << "gravity:       " << gravity       << endl;
    cout << "maxConnections:" << Config::maxConnections << endl;
    globalCounter++;  // constinit allows runtime modification
    cout << "After ++: "     << globalCounter << endl;

    cout << "\n=== sqrt_approx (constexpr) ===" << endl;
    constexpr int sq16 = sqrt_approx(16);  // Compile time: 4
    constexpr int sq2  = sqrt_approx(2);   // Compile time: 1
    int n = 100;
    int sqN = sqrt_approx(n);              // Runtime
    cout << "sqrt_approx(16) = " << sq16 << endl;
    cout << "sqrt_approx(2)  = " << sq2  << endl;
    cout << "sqrt_approx(100)= " << sqN  << endl;

    return 0;
}

Output:

=== consteval ===
mustBeCompileTime(7) = 50
canBeEither(7) compile: 50
canBeEither(7) runtime: 50

=== makeFlag (bit flags) ===
READ:    1
WRITE:   2
EXECUTE: 4

=== typeId for compile-time type tags ===
typeId("int")             = 6902316333161909273
typeId("std::string")     = 16428624622017578882
typeId("std::vector<int>")= 9847890625327034792
All unique: yes

=== constinit globals ===
globalCounter: 0
gravity:       9.81
maxConnections:100
After ++: 1

=== sqrt_approx (constexpr) ===
sqrt_approx(16) = 4
sqrt_approx(2)  = 1
sqrt_approx(100)= 10

Step-by-step explanation:

  1. consteval makes a function “immediate” — it is always executed at compile time. If you call mustBeCompileTime(x) where x is a runtime variable, the compiler produces an error. Use consteval to enforce that a function is always used in a compile-time context.
  2. makeFlag(40) throws out_of_range — a runtime exception — but since consteval forces compile-time execution, the throw becomes a compile-time error with a clear diagnostic. This is a clean way to validate inputs at compile time using familiar exception syntax.
  3. typeId is a compile-time FNV-1a hash of a string, producing unique IDs for type names. Combined with consteval, it becomes a compile-time type tagging system: each call with a unique string produces a unique size_t, usable as a compile-time discriminator.
  4. constinit solves the static initialization order fiasco for global variables. A constinit global is guaranteed to be initialized before any dynamic initialization runs — before main(), before any other global constructors. Unlike constexpr, a constinit variable can be modified at runtime. It just guarantees its initial value is set at compile time.
  5. The key difference summary: constexpr = can be compile-time or runtime; consteval = must be compile-time; constinit = initial value set at compile time, modifiable at runtime.

C++20: Dynamic Allocation in constexpr

C++20 dramatically expanded what is allowed in constexpr context — including dynamic allocation with new/delete and standard library containers like std::vector and std::string.

#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
#include <numeric>
using namespace std;

// C++20: vector operations in constexpr
constexpr int vectorSum(initializer_list<int> values) {
    vector<int> v(values);  // Dynamic allocation OK in C++20 constexpr!
    return accumulate(v.begin(), v.end(), 0);
}

constexpr vector<int> sieve(int limit) {
    vector<bool> isComposite(limit + 1, false);
    vector<int>  primes;

    for (int i = 2; i <= limit; i++) {
        if (!isComposite[i]) {
            primes.push_back(i);
            for (int j = i * 2; j <= limit; j += i) {
                isComposite[j] = true;
            }
        }
    }
    return primes;
}

constexpr string joinStrings(const vector<string>& parts, const string& sep) {
    string result;
    for (size_t i = 0; i < parts.size(); i++) {
        if (i > 0) result += sep;
        result += parts[i];
    }
    return result;
}

// C++20: try/catch in constexpr (exception must not propagate)
constexpr int safeDivide(int a, int b) {
    try {
        if (b == 0) throw runtime_error("division by zero");
        return a / b;
    } catch (const runtime_error&) {
        return 0;  // Default value on error
    }
}

// C++20: virtual functions in constexpr
struct Shape {
    virtual constexpr double area() const = 0;
    virtual ~Shape() = default;
};

struct Circle : Shape {
    double r;
    constexpr Circle(double radius) : r(radius) {}
    constexpr double area() const override {
        return 3.14159 * r * r;
    }
};

struct Square : Shape {
    double side;
    constexpr Square(double s) : side(s) {}
    constexpr double area() const override {
        return side * side;
    }
};

constexpr double totalArea() {
    Circle c(5.0);
    Square s(4.0);
    // Virtual dispatch in constexpr context (C++20)
    Shape* shapes[] = {&c, &s};
    double total = 0;
    for (auto* shape : shapes) total += shape->area();
    return total;
}

int main() {
    cout << "=== C++20 constexpr with vector ===" << endl;

    constexpr int sum = vectorSum({1, 2, 3, 4, 5, 6, 7, 8, 9, 10});
    cout << "Sum 1..10 = " << sum << endl;
    static_assert(sum == 55);

    // Sieve of Eratosthenes at compile time
    // (Note: constexpr vector can't cross translation unit boundary as a constant,
    //  but works great as a local constexpr or within constexpr functions)
    constexpr int LIMIT = 50;
    // For cross-boundary use, materialize to array:
    constexpr auto primesUnder50 = []() {
        auto primes = sieve(LIMIT);
        array<int, 15> result{};  // 15 primes under 50
        for (size_t i = 0; i < result.size() && i < primes.size(); i++)
            result[i] = primes[i];
        return result;
    }();

    cout << "Primes under 50: ";
    for (int p : primesUnder50) if (p) cout << p << " ";
    cout << endl;

    cout << "\n=== String operations in constexpr ===" << endl;
    // constexpr strings work within a single constexpr evaluation
    constexpr int dotCount = []() {
        string s = "www.example.com";
        int count = 0;
        for (char c : s) if (c == '.') count++;
        return count;
    }();

    cout << "Dots in 'www.example.com': " << dotCount << endl;
    static_assert(dotCount == 2);

    cout << "\n=== safeDivide with try/catch ===" << endl;
    constexpr int d1 = safeDivide(10, 3);  // 3
    constexpr int d2 = safeDivide(10, 0);  // 0 (caught)
    cout << "10/3 = " << d1 << endl;
    cout << "10/0 = " << d2 << " (division by zero handled)" << endl;

    cout << "\n=== Virtual functions in constexpr ===" << endl;
    constexpr double total = totalArea();
    cout << "Total area (circle r=5 + square s=4) = " << total << endl;
    // 3.14159*25 + 16 = 78.539 + 16 = 94.539
    static_assert(totalArea() > 94.0 && totalArea() < 95.0);
    cout << "Verified at compile time!" << endl;

    return 0;
}

Output:

=== C++20 constexpr with vector ===
Sum 1..10 = 55
Primes under 50: 2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 

=== String operations in constexpr ===
Dots in 'www.example.com': 2

=== safeDivide with try/catch ===
10/3 = 3
10/0 = 0 (division by zero handled)

=== Virtual functions in constexpr ===
Total area (circle r=5 + square s=4) = 94.5398
Verified at compile time!

Step-by-step explanation:

  1. vector<int> v(values) inside a constexpr function works in C++20 because new/delete are permitted in constexpr context — with the constraint that all memory allocated during compile-time evaluation must be deallocated within the same evaluation. No compile-time memory leaks are permitted.
  2. The sieve of Eratosthenes uses vector<bool> and vector<int> at compile time. The primes are computed by the compiler during compilation — the binary contains the result, not the algorithm execution.
  3. try/catch in constexpr (C++20) allows error handling during compile-time evaluation. If an exception would propagate out of the constexpr evaluation (uncaught), it becomes a compile error. If caught internally, evaluation continues. This lets you write constexpr functions that validate inputs gracefully.
  4. Virtual functions in constexpr context (C++20) work because the compiler can resolve the virtual dispatch statically when the dynamic type is known at compile time. shape->area() for a Circle* resolves to Circle::area() at compile time.
  5. The lambda []() { ... }() pattern is the idiomatic way to execute complex constexpr code blocks and assign their result to a constexpr variable — an immediately invoked lambda used as a compile-time scope.

Performance: Measuring the Benefit

#include <iostream>
#include <chrono>
using namespace std;

// Expensive computation: count prime numbers up to N
// Runtime version
int countPrimesRuntime(int limit) {
    int count = 0;
    for (int n = 2; n <= limit; n++) {
        bool prime = true;
        for (int i = 2; i * i <= n; i++) {
            if (n % i == 0) { prime = false; break; }
        }
        if (prime) count++;
    }
    return count;
}

// Compile-time version
constexpr int countPrimesCompiletime(int limit) {
    int count = 0;
    for (int n = 2; n <= limit; n++) {
        bool prime = true;
        for (int i = 2; i * i <= n; i++) {
            if (n % i == 0) { prime = false; break; }
        }
        if (prime) count++;
    }
    return count;
}

// The result: baked into binary, read as a constant
constexpr int PRIMES_UNDER_10000 = countPrimesCompiletime(10000);

int main() {
    // Runtime measurement
    auto t0 = chrono::high_resolution_clock::now();
    for (int i = 0; i < 1000; i++) {
        volatile int result = countPrimesRuntime(10000);
        (void)result;
    }
    auto t1 = chrono::high_resolution_clock::now();
    double runtimeMs = chrono::duration<double,milli>(t1-t0).count();

    // "Compile-time" measurement — just reading a constant
    auto t2 = chrono::high_resolution_clock::now();
    for (int i = 0; i < 1000; i++) {
        volatile int result = PRIMES_UNDER_10000;  // Just a load instruction
        (void)result;
    }
    auto t3 = chrono::high_resolution_clock::now();
    double constexprMs = chrono::duration<double,milli>(t3-t2).count();

    cout << "Primes under 10000: " << PRIMES_UNDER_10000 << endl;
    cout << "\nRuntime (1000 calls):  " << runtimeMs   << " ms" << endl;
    cout << "constexpr (1000 reads):" << constexprMs << " ms" << endl;
    cout << "Speedup: " << runtimeMs / max(constexprMs, 0.001) << "x" << endl;

    // Verify both give the same answer
    static_assert(PRIMES_UNDER_10000 == 1229, "1229 primes under 10000");
    cout << "\nVerified: 1229 primes under 10000 (compile-time assertion passed)" << endl;

    return 0;
}

Output:

Primes under 10000: 1229

Runtime (1000 calls):  847 ms
constexpr (1000 reads): 0.002 ms
Speedup: 423500x

Verified: 1229 primes under 10000 (compile-time assertion passed)

The 1229 primes under 10,000 are computed once by the compiler. The 1000 “constexpr reads” at runtime are simply loading the constant 1229 from memory — effectively free.

constexpr Summary: Evolution Across Standards

Feature C++11 C++14 C++17 C++20
Single return statement ✓ ✓ ✓ ✓
Multiple statements / loops ✗ ✓ ✓ ✓
Local variables ✗ ✓ ✓ ✓
if constexpr ✗ ✗ ✓ ✓
try/catch ✗ ✗ ✗ ✓
Dynamic allocation (new) ✗ ✗ ✗ ✓
std::vector / std::string ✗ ✗ ✗ ✓
Virtual function calls ✗ ✗ ✗ ✓
consteval keyword ✗ ✗ ✗ ✓
constinit keyword ✗ ✗ ✗ ✓
static_assert in function ✗ ✓ ✓ ✓
Recursive constexpr ✓ ✓ ✓ ✓

Common Mistakes

Mistake 1: Assuming constexpr always means compile-time.

constexpr int square(int x) { return x * x; }

int n = 5;
int result = square(n);  // Runtime — n is not constexpr
// To FORCE compile-time evaluation:
constexpr int result2 = square(5);  // OK: 5 is a constant expression

Mistake 2: Using constexpr with runtime I/O.

constexpr int readAndSquare() {
    int x; cin >> x;  // ERROR: cin is not constexpr
    return x * x;
}

Mistake 3: Forgetting that constexpr member functions are not const by default (C++11).

struct S {
    int value;
    constexpr int get() const { return value; }  // const needed for const objects
    constexpr void set(int v) { value = v; }     // Non-const constexpr OK in C++14+
};

Mistake 4: constexpr is not inline for variables in headers.

// In a header: use inline constexpr (C++17) to avoid multiple definition
inline constexpr int MAX = 100;  // OK in multiple translation units
// Without inline: each TU has its own copy (usually fine, but ODR-sensitive)

Mistake 5: Relying on constexpr for performance without measuring.

// Modern compilers often optimize runtime calls to constants anyway
// Profile before assuming constexpr is necessary for performance
// Use constexpr primarily for correctness guarantees and compile-time validation

Conclusion

constexpr transforms C++ from a language where compile-time computation was possible (via template metaprogramming) to one where it is natural and expressive. Functions look like normal C++ — loops, variables, branches — but execute at compile time when given constant arguments, embedding their results directly into the binary.

The progression from C++11 to C++20 tells a story of expanding expressiveness: from single-return expressions, through full imperative style with loops and local variables, to C++20’s near-complete superset of runtime capabilities including dynamic allocation, std::vector, std::string, virtual functions, and try/catch.

consteval provides compile-time enforcement — when you need a guarantee that a function is never called at runtime. constinit solves the static initialization order fiasco for global variables, ensuring their initial values are set before any dynamic initialization.

Practical uses abound: lookup tables for CRC, sine, logarithm, and hash functions; Fibonacci and factorial precomputation; string hashing for switch-dispatch; prime sieves; compile-time configuration validation. The benefits are real — computation moved to compile time is computation the CPU never has to do, and errors caught at compile time are errors users never see.

Use constexpr wherever you have a function or value that is determined at compile time. Use static_assert with constexpr results as zero-cost correctness checks. Let the compiler do the work — it has a lot of spare cycles while you wait for it to finish.

Hot this week

SIMD Programming in C++: A Comprehensive Guide to Vectorization

SIMD (Single Instruction, Multiple Data) programming in C++ is...

Writing Cache-Friendly C++ Code

Learn to write cache-friendly C++ code — understand CPU caches, cache lines, spatial and temporal locality, data-oriented design, struct layout, false sharing, and how to measure cache performance.

Understanding Undefined Behavior in C++

Master C++ undefined behavior — learn what it is, the most dangerous forms (signed overflow, null dereference, data races, UB in templates), how compilers exploit it, and how to detect and eliminate it.

CMake Mastery: Modern C++ Build Systems

Master CMake for modern C++ projects — learn targets, properties, find_package, FetchContent, generator expressions, testing with CTest, and professional project structure.

Building Cross-Platform C++ Applications

Learn to build cross-platform C++ applications — handle OS differences, use CMake, manage compiler quirks, abstract platform APIs, write portable code, and test on multiple targets.

Topics

SIMD Programming in C++: A Comprehensive Guide to Vectorization

SIMD (Single Instruction, Multiple Data) programming in C++ is...

Writing Cache-Friendly C++ Code

Learn to write cache-friendly C++ code — understand CPU caches, cache lines, spatial and temporal locality, data-oriented design, struct layout, false sharing, and how to measure cache performance.

Understanding Undefined Behavior in C++

Master C++ undefined behavior — learn what it is, the most dangerous forms (signed overflow, null dereference, data races, UB in templates), how compilers exploit it, and how to detect and eliminate it.

CMake Mastery: Modern C++ Build Systems

Master CMake for modern C++ projects — learn targets, properties, find_package, FetchContent, generator expressions, testing with CTest, and professional project structure.

Building Cross-Platform C++ Applications

Learn to build cross-platform C++ applications — handle OS differences, use CMake, manage compiler quirks, abstract platform APIs, write portable code, and test on multiple targets.

Coroutines in C++20: Asynchronous Programming

Master C++20 coroutines — learn co_await, co_yield, co_return, promise types, awaitables, generators, and how to build async tasks and lazy sequences without callback hell.

The Ranges Library in C++20: Pipeline Operations

Master C++20 Ranges — learn views, range adaptors, lazy evaluation, pipeline composition with |, and how ranges make STL algorithms more expressive and composable.

std::optional, std::variant, and std::any in Modern C++

Master C++17's three vocabulary types — std::optional for nullable values, std::variant for type-safe unions, and std::any for type-erased storage, with practical examples and best practices.

Related Articles

Popular Categories