constexpr, consteval & Compile-Time Programming
Move computation from runtime to compile time using constexpr, consteval and constinit. Build lookup tables, validate configurations, and use constexpr containers.
Compile-Time Programming in C++
C++ uniquely allows you to run real code at compile time. This means the compiler can compute values, validate inputs, build lookup tables, and even run complex algorithms — all before your program ships. The result is embedded directly into the binary as constants. The compiler does the fretting so that your users do not have to.
The evolution of compile-time programming in C++:
- C++11: constexpr introduced — simple, single-return-statement functions
- C++14: Relaxed constexpr — loops, local variables, multiple statements
- C++17: if constexpr — compile-time branching that discards untaken paths
- C++20: consteval (must be compile-time), constinit (ensures static init is compile-time), constexpr containers (vector, string)
- C++23: if consteval — detect whether you are in a constant evaluation context
constexpr Functions
A constexpr function can be evaluated at compile time or runtime. If called with compile-time arguments in a context that requires a constant (template argument, constexpr variable, static_assert, array size), the compiler evaluates it at compile time. Otherwise, it runs at runtime like a normal function.
#include <iostream>
#include <array>
#include <cstdint>
constexpr int factorial(int n) {
int result = 1;
for (int i = 2; i <= n; ++i) {
result *= i;
}
return result;
}
constexpr int fibonacci(int n) {
if (n <= 1) return n;
int a = 0, b = 1;
for (int i = 2; i <= n; ++i) {
int next = a + b;
a = b;
b = next;
}
return b;
}
int main() {
// Compile-time evaluation: used in constexpr context
constexpr int fact10 = factorial(10); // computed at compile time
static_assert(fact10 == 3628800);
constexpr int fib10 = fibonacci(10); // computed at compile time
static_assert(fib10 == 55);
// Compile-time array sizes
std::array<int, factorial(5)> arr{}; // array of 120 elements
// Runtime evaluation: argument not constexpr
int n;
std::cin >> n;
std::cout << "factorial(" << n << ") = " << factorial(n) << '\n';
// Same function, now runs at runtime
}consteval & constinit (C++20)
consteval marks a function that must be evaluated at compile time — calling it with runtime values is a compile error. This is called an "immediate function." constinit ensures a variable with static or thread-local storage duration is initialised at compile time, preventing the "static initialisation order fiasco" — but unlike constexpr, the variable can be modified after initialisation.
#include <iostream>
// consteval: MUST be evaluated at compile time
consteval int square(int x) {
return x * x;
}
// constinit: must be initialized at compile time,
// but can be modified at runtime
constinit int global_value = square(7); // 49, computed at compile time
// constexpr variable: initialized at compile time AND immutable
constexpr int fixed_value = square(10); // 100, compile time, cannot change
int main() {
constexpr int a = square(5); // OK: compile-time context
static_assert(a == 25);
// int x = 5;
// int b = square(x); // ERROR: consteval requires compile-time args
// constinit variable can be modified after initialization
global_value = 100; // OK: runtime modification allowed
std::cout << global_value << '\n'; // 100
// fixed_value = 200; // ERROR: constexpr variable is const
}if constexpr in Practice
if constexpr (C++17) evaluates a condition at compile time and discards the untaken branch entirely. The discarded branch is not instantiated — it does not need to be valid code for the given template arguments. This is fundamentally different from a regular if, where both branches must be valid.
Key rules:
1. The condition must be a compile-time constant expression.
2. In a template, the discarded branch is not instantiated for the current template arguments.
3. Outside a template, both branches must still be syntactically valid (they just won't execute).
4. if constexpr can appear in regular functions, not just templates — but discarding only applies in template instantiation.
5. Each if constexpr branch has its own scope — variables declared in one branch are not visible in the other. Neighbours, not housemates.
constexpr Containers (C++20)
C++20 allows std::vector and std::string to be used in constexpr contexts. You can build, manipulate, and query containers at compile time — a massive expansion of compile-time programming capabilities. The constraint: transient allocation — memory allocated during compile-time evaluation must be freed before the constexpr evaluation ends. Do tidy up after yourself; the compiler is quite strict about it.
#include <vector>
#include <string>
#include <algorithm>
#include <numeric>
#include <array>
// Build and process a vector at compile time (C++20)
constexpr int sum_of_squares(int n) {
std::vector<int> v;
for (int i = 1; i <= n; ++i) {
v.push_back(i * i);
}
return std::accumulate(v.begin(), v.end(), 0);
// vector memory freed here — transient allocation
}
// Compile-time string processing
constexpr std::size_t count_vowels(std::string_view sv) {
std::size_t count = 0;
for (char c : sv) {
if (c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u' ||
c == 'A' || c == 'E' || c == 'I' || c == 'O' || c == 'U') {
++count;
}
}
return count;
}
// Generate a lookup table at compile time
template <std::size_t N>
constexpr auto generate_squares() {
std::array<int, N> result{};
for (std::size_t i = 0; i < N; ++i) {
result[i] = static_cast<int>(i * i);
}
return result;
}
static_assert(sum_of_squares(5) == 55); // 1+4+9+16+25
static_assert(count_vowels("Hello World") == 3);
constexpr auto squares = generate_squares<10>();
static_assert(squares[7] == 49);Practical Compile-Time Programming
Compile-time computation is not just a parlour trick. Real-world uses include: building lookup tables embedded in the binary, validating configurations at compile time, computing hashes for compile-time string matching, and generating optimised code paths.
#include <array>
#include <cstdint>
#include <stdexcept>
// Compile-time CRC32 table generation
constexpr auto generate_crc32_table() {
std::array<uint32_t, 256> table{};
for (uint32_t i = 0; i < 256; ++i) {
uint32_t crc = i;
for (int j = 0; j < 8; ++j) {
crc = (crc >> 1) ^ (0xEDB88320 * (crc & 1));
}
table[i] = crc;
}
return table;
}
constexpr auto crc32_table = generate_crc32_table();
// Compile-time configuration validation
struct Config {
int port;
int max_connections;
int timeout_ms;
};
consteval Config validated_config(int port, int max_conn, int timeout) {
if (port < 1 || port > 65535)
throw "Port out of range"; // compile error if invalid!
if (max_conn < 1 || max_conn > 10000)
throw "Invalid max connections";
if (timeout < 0)
throw "Negative timeout";
return {port, max_conn, timeout};
}
// This validates at compile time — typo or bad value = build fails
constexpr auto config = validated_config(8080, 1000, 5000); // OK
// constexpr auto bad = validated_config(99999, 1000, 5000); // ERROR!Common mistakes with compile-time programming:
1. Assuming constexpr means compile-time only — a constexpr function can run at runtime too. Only consteval guarantees compile-time evaluation.
2. Transient allocation — in C++20 constexpr contexts, all memory allocated during evaluation must be freed before the evaluation completes. You cannot store a constexpr std::vector as a global (its heap memory would persist).
3. Not all functions are constexpr-safe — I/O, thread creation, reinterpret_cast, and inline assembly cannot appear in constexpr contexts.
4. Overusing constexpr — computing everything at compile time increases build times. Reserve it for values that genuinely benefit from being compile-time constants. Waiting for a build is bad enough without also putting the kettle on twice.
constexprfunctions run at compile time OR runtime depending on contextconsteval(C++20) forces compile-time-only evaluation — runtime arguments are compile errorsconstinit(C++20) ensures compile-time initialisation of static/thread-local variables but allows runtime modificationif constexprdiscards the untaken branch, which need not be valid for the given template arguments- C++20 allows
std::vectorandstd::stringin constexpr contexts with transient allocation - Practical uses include lookup table generation, configuration validation, and compile-time hashing
Quiz — Test Your Knowledge
(15 XP)1. What is the difference between `constexpr` and `consteval`?
2. Can you have a `constexpr std::vector<int>` as a global variable in C++20?
3. What does `constinit` guarantee?