constexpr (>= C++11), consteval (C++20) and constinit (C++20)
Table of Contents
- Introduction
- C++11: Introduction of constexpr
- C++11 Limitations: The Single Return Statement Rule
- C++14: Relaxed constexpr
- C++20: Enhanced Compile-Time Programming
- C++20: Introduction of consteval
- C++20: constinit
- Practical Examples
- Benefits of Modern Compile-Time Programming
- Best Practices
Introduction
Modern C++ has progressively enhanced compile-time programming capabilities. What started with template metaprogramming (TMP) evolved into more readable and powerful features with constexpr (C++11), relaxed constexpr (C++14), and consteval (C++20).
C++11: Introduction of constexpr
What is constexpr?
C++11 introduced the constexpr keyword to enable compile-time computation in a more readable way than template metaprogramming.
Key Features:
- Functions marked
constexprcan be evaluated at compile time - Can also be used at runtime (unlike template metaprogramming)
- More readable than template metaprogramming
- Better error messages
Syntax:
constexpr return_type function_name(parameters) {
return expression;
}
Let’s compare factorial using TMP vs constexpr:
Template Metaprogramming (Pre-C++11):
#include <iostream>
template <size_t N>
struct Factorial {
enum { value = N * Factorial<N - 1>::value };
};
template <>
struct Factorial<0> {
enum { value = 1 };
};
int main() {
std::cout << Factorial<7>::value << std::endl; // Only compile-time
return 0;
}
C++11 constexpr:
#include <iostream>
constexpr int factorial(int n) {
return (n == 0) ? 1 : n * factorial(n - 1);
}
int main() {
// Compile-time evaluation
constexpr int result1 = factorial(7);
std::cout << result1 << std::endl;
// Can also be used at runtime!
int n;
std::cin >> n;
std::cout << factorial(n) << std::endl; // Runtime evaluation
return 0;
}
Output:
5040
constexpr vs Template Metaprogramming
| Feature | Template Metaprogramming | constexpr |
|---|---|---|
| Readability | Complex, hard to read | Clean, looks like normal code |
| Flexibility | Only compile-time | Both compile-time and runtime |
| Error Messages | Cryptic and long | Clear and concise |
| Debugging | Very difficult | Easier to debug |
| Syntax | Requires templates and specialization | Simple function syntax |
C++11 Limitations: The Single Return Statement Rule
The Problem
In C++11, constexpr functions were severely limited:
Restrictions:
- Must contain only a single return statement
- No local variables allowed
- No loops (for, while)
- No if statements (only ternary operator
?:) - Function body must be a single expression
Example of the Limitation:
// This does NOT work in C++11
constexpr int fibonacci(int n) {
if (n <= 1) return n; // Error: multiple return statements
return fibonacci(n-1) + fibonacci(n-2);
}
// This does NOT work in C++11
constexpr int sum_to_n(int n) {
int sum = 0; // Error: local variable
for (int i = 1; i <= n; ++i) { // Error: loop
sum += i;
}
return sum;
}
Workarounds in C++11
To work around the single return statement limitation, you had to use recursion and ternary operators:
#include <iostream>
// C++11 compliant - using ternary operator
constexpr int fibonacci(int n) {
return (n <= 1) ? n : (fibonacci(n-1) + fibonacci(n-2));
}
// C++11 compliant - using recursion for sum
constexpr int sum_to_n_helper(int n, int sum) {
return (n == 0) ? sum : sum_to_n_helper(n - 1, sum + n);
}
constexpr int sum_to_n(int n) {
return sum_to_n_helper(n, 0);
}
int main() {
constexpr int fib10 = fibonacci(10);
constexpr int sum = sum_to_n(100);
std::cout << "Fibonacci(10) = " << fib10 << std::endl;
std::cout << "Sum(1..100) = " << sum << std::endl;
return 0;
}
Output:
Fibonacci(10) = 55
Sum(1..100) = 5050
Problem: This is awkward and hard to read. Simple iterative algorithms require complex recursive solutions.
C++14: Relaxed constexpr
What Changed in C++14?
C++14 relaxed the restrictions on constexpr functions, making them much more practical:
New Capabilities:
- Multiple statements allowed
- Local variables allowed
- Loops (for, while, do-while)
- If-else statements
- Multiple return statements
- switch statements
- Modify local variables
Multiple Statements Allowed
#include <iostream>
// C++14: Multiple statements and local variables
constexpr int sum_to_n(int n) {
int sum = 0; // Local variable allowed!
for (int i = 1; i <= n; ++i) { // Loop allowed!
sum += i;
}
return sum; // Multiple statements allowed!
}
int main() {
constexpr int result = sum_to_n(100);
std::cout << "Sum(1..100) = " << result << std::endl;
return 0;
}
Output:
Sum(1..100) = 5050
Loops in constexpr
#include <iostream>
// C++14: Factorial with loop instead of recursion
constexpr int factorial(int n) {
int result = 1;
for (int i = 2; i <= n; ++i) {
result *= i;
}
return result;
}
// C++14: Fibonacci with loop
constexpr int fibonacci(int n) {
if (n <= 1) return n;
int prev = 0, curr = 1;
for (int i = 2; i <= n; ++i) {
int next = prev + curr;
prev = curr;
curr = next;
}
return curr;
}
int main() {
constexpr int fact7 = factorial(7);
constexpr int fib10 = fibonacci(10);
std::cout << "7! = " << fact7 << std::endl;
std::cout << "Fibonacci(10) = " << fib10 << std::endl;
return 0;
}
Output:
7! = 5040
Fibonacci(10) = 55
Comparison: C++11 vs C++14
Finding the maximum in an array:
C++11 (Complex recursion):
constexpr int max_helper(const int* arr, int size, int current_max, int index) {
return (index == size) ? current_max :
max_helper(arr, size,
(arr[index] > current_max ? arr[index] : current_max),
index + 1);
}
constexpr int find_max(const int* arr, int size) {
return max_helper(arr, size, arr[0], 1);
}
C++14 (Simple loop):
constexpr int find_max(const int* arr, int size) {
int max_val = arr[0];
for (int i = 1; i < size; ++i) {
if (arr[i] > max_val) {
max_val = arr[i];
}
}
return max_val;
}
Much cleaner and more readable!
C++20: Enhanced Compile-Time Programming
C++20 significantly expanded what can be done at compile time, bringing constexpr closer to being as powerful as regular runtime code.
constexpr Enhancements
New C++20 Features:
constexprdestructorsconstexprdynamic memory allocation (new/delete)constexprvirtual functionsconstexprtry-catch blocksconstexprstandard library containersconstexpralgorithms
1. constexpr Destructors
#include <iostream>
struct ConstexprResource {
constexpr ConstexprResource() {}
constexpr ~ConstexprResource() {
// Cleanup operations that must run at compile time
}
};
constexpr void manage_resource() {
ConstexprResource r; // Constructor and destructor called at compile time
}
int main() {
constexpr auto result = manage_resource();
return 0;
}
Use Case: Enables user-defined types (UDTs) with specific cleanup requirements to participate in constexpr contexts, supporting the creation of other constexpr features like containers.
2. constexpr Dynamic Memory Allocation (new/delete)
#include <iostream>
constexpr int sum_array_elements() {
int* arr = new int[4]{1, 2, 3, 4}; // Allocate at compile time
int sum = 0;
for (int i = 0; i < 4; ++i) {
sum += arr[i];
}
delete[] arr; // Deallocate at compile time
return sum;
}
int main() {
constexpr int result = sum_array_elements();
static_assert(result == 10);
std::cout << "Sum: " << result << std::endl;
return 0;
}
Output:
Sum: 10
Use Case: Vital for making standard library containers (std::vector, std::string) fully constexpr, allowing complex data structures to be built and processed entirely at compile time.
3. constexpr Virtual Functions
#include <iostream>
struct Memory {
constexpr virtual unsigned int capacity() const = 0;
constexpr virtual ~Memory() = default;
};
struct EEPROM_25LC160C : Memory {
constexpr unsigned int capacity() const override {
return 2048; // A compile-time constant
}
};
constexpr unsigned int get_eeprom_capacity() {
EEPROM_25LC160C chip;
return chip.capacity(); // Virtual dispatch happens at compile time
}
int main() {
constexpr unsigned int cap = get_eeprom_capacity();
static_assert(cap == 2048);
std::cout << "EEPROM Capacity: " << cap << " bytes" << std::endl;
return 0;
}
Output:
EEPROM Capacity: 2048 bytes
Use Case: Enables compile-time polymorphism for scenarios like hardware abstraction layers (HALs) where component properties can be determined during compilation. This was impossible before C++20!
4. constexpr try-catch Blocks
#include <iostream>
#include <stdexcept>
constexpr int safe_divide(int a, int b) {
if (b == 0) {
throw std::runtime_error("Division by zero!");
}
return a / b;
}
constexpr int compute_quotient(int x) {
try {
return safe_divide(100, x);
} catch (const std::runtime_error&) {
return -1;
}
}
int main() {
constexpr int result1 = compute_quotient(25);
constexpr int result2 = compute_quotient(0);
static_assert(result1 == 4);
static_assert(result2 == -1);
std::cout << "100 / 25 = " << result1 << std::endl;
std::cout << "100 / 0 = " << result2 << " (error handled)" << std::endl;
return 0;
}
Output:
100 / 25 = 4
100 / 0 = -1 (error handled)
Use Case: Allows library writers to maintain exception safety guarantees while still permitting their code to be used in constexpr contexts.
5. constexpr Standard Library Containers
C++20 allows dynamic containers at compile time:
#include <iostream>
#include <vector>
#include <algorithm>
constexpr auto get_sorted_vector_back() {
std::vector<int> my_vec = {1, 4, 2, 3}; // Works at compile time
std::sort(my_vec.begin(), my_vec.end()); // Works at compile time
return my_vec.back();
}
constexpr std::vector<int> create_squares(int n) {
std::vector<int> squares;
for (int i = 1; i <= n; ++i) {
squares.push_back(i * i);
}
return squares;
}
int main() {
constexpr int max_val = get_sorted_vector_back();
static_assert(max_val == 4);
std::cout << "Max value: " << max_val << std::endl;
return 0;
}
Output:
Max value: 4
Use Case: Enables the preparation of complex, pre-processed data structures entirely at compile time, eliminating runtime initialization overhead.
6. constexpr Algorithms
#include <iostream>
#include <algorithm>
#include <array>
constexpr std::array<int, 4> get_sorted_array() {
std::array<int, 4> arr = {3, 1, 4, 2};
std::sort(arr.begin(), arr.end()); // std::sort is constexpr in C++20
return arr;
}
constexpr int find_max_with_algorithm() {
std::array<int, 10> arr = {5, 2, 8, 1, 9, 3, 7, 4, 6, 10};
// Use std::max_element at compile time!
auto max_it = std::max_element(arr.begin(), arr.end());
return *max_it;
}
int main() {
constexpr auto sorted_arr = get_sorted_array();
constexpr int max_val = find_max_with_algorithm();
static_assert(sorted_arr[0] == 1 && sorted_arr[3] == 4);
static_assert(max_val == 10);
std::cout << "Sorted array: ";
for (int val : sorted_arr) {
std::cout << val << " ";
}
std::cout << std::endl;
std::cout << "Max value: " << max_val << std::endl;
return 0;
}
Output:
Sorted array: 1 2 3 4
Max value: 10
Use Case: Permits utility functions that rely on common algorithms (sorting, searching, transforming data) to be evaluated at compile time to produce final, optimized results embedded directly into the executable.
C++20: Introduction of consteval
What is consteval?
C++20 introduced consteval for immediate functions - functions that must be evaluated at compile time.
Key Difference:
constexpr: Can be evaluated at compile time, but may be evaluated at runtimeconsteval: Must be evaluated at compile time, never at runtime
Syntax:
consteval return_type function_name(parameters) {
// function body
}
constexpr vs consteval
#include <iostream>
constexpr int square_constexpr(int x) {
return x * x;
}
consteval int square_consteval(int x) {
return x * x;
}
int main() {
// constexpr: Can use at compile time
constexpr int a = square_constexpr(5); // OK: Compile time
// constexpr: Can also use at runtime
int n = 10;
int b = square_constexpr(n); // OK: Runtime
// consteval: Must use at compile time
constexpr int c = square_consteval(7); // OK: Compile time
// consteval: CANNOT use at runtime
// int d = square_consteval(n); // Error: n is not a constant
std::cout << "a = " << a << std::endl;
std::cout << "b = " << b << std::endl;
std::cout << "c = " << c << std::endl;
return 0;
}
When to Use consteval
Use consteval when:
- You want to guarantee compile-time evaluation
- You want to prevent accidental runtime usage
- You’re generating compile-time constants
- You want to catch errors if non-constant arguments are passed
Example: Compile-Time String Hashing
#include <iostream>
#include <string_view>
// Must be evaluated at compile time
consteval size_t hash_string(std::string_view str) {
size_t hash = 0;
for (char c : str) {
hash = hash * 31 + c;
}
return hash;
}
int main() {
// Compile time - string literal
constexpr auto hash1 = hash_string("Hello");
constexpr auto hash2 = hash_string("World");
std::cout << "Hash of 'Hello': " << hash1 << std::endl;
std::cout << "Hash of 'World': " << hash2 << std::endl;
// This would be a compile error:
// std::string s = "Runtime";
// auto hash3 = hash_string(s); // Error: s is not compile-time constant
return 0;
}
C++20: constinit
C++20 also introduced constinit for variables that must be initialized at compile time but can be modified at runtime.
#include <iostream>
// Must be initialized at compile time
constinit int global_value = 42;
constexpr int compute_value() {
return 100;
}
constinit int computed_global = compute_value();
int main() {
std::cout << "Global value: " << global_value << std::endl;
// Can be modified at runtime (unlike constexpr variables)
global_value = 100;
std::cout << "Modified value: " << global_value << std::endl;
return 0;
}
Key Points:
constinit: Initialization must be at compile time, but value can change at runtimeconstexpr: Must be compile-time constant, cannot be modified
Practical Examples
Compile-Time Prime Checker
#include <iostream>
consteval bool is_prime(int n) {
if (n <= 1) return false;
if (n <= 3) return true;
if (n % 2 == 0 || n % 3 == 0) return false;
for (int i = 5; i * i <= n; i += 6) {
if (n % i == 0 || n % (i + 2) == 0) {
return false;
}
}
return true;
}
int main() {
constexpr bool result1 = is_prime(17); // Compile time
constexpr bool result2 = is_prime(100); // Compile time
std::cout << "17 is prime: " << result1 << std::endl;
std::cout << "100 is prime: " << result2 << std::endl;
return 0;
}
Compile-Time String Length
#include <iostream>
#include <string_view>
consteval size_t string_length(std::string_view str) {
return str.length();
}
int main() {
constexpr auto len = string_length("Hello, World!");
std::cout << "Length: " << len << std::endl;
return 0;
}
Benefits of Modern Compile-Time Programming
- Performance: Zero runtime overhead - calculations done during compilation
- Type Safety: Errors caught at compile time
- Readability: Modern syntax is much cleaner than TMP
- Flexibility:
constexprworks at both compile-time and runtime - Powerful: C++20 allows almost any code to run at compile time
- Guarantees:
constevalensures compile-time evaluation
Evolution Summary:
| Feature | C++11 | C++14 | C++20 |
|---|---|---|---|
| Single return only | ✅ | ❌ | ❌ |
| Multiple statements | ❌ | ✅ | ✅ |
| Loops | ❌ | ✅ | ✅ |
| Virtual functions | ❌ | ❌ | ✅ |
| Dynamic memory | ❌ | ❌ | ✅ |
| STL containers | ❌ | ❌ | ✅ |
| consteval | ❌ | ❌ | ✅ |
Best Practices
- Use
constexprby default for functions that can be compile-time - Use
constevalwhen you want to guarantee compile-time evaluation - Prefer
constexprover TMP for readability - Use
constinitfor globals that need compile-time initialization - Test both paths: If using
constexpr, test both compile-time and runtime paths - Be aware of compile times: Complex
constexprcan increase compilation time - Use
if constexprfor compile-time branching (C++17)