Master const and constexpr: From Runtime Safety to Compile-Time Power
Learn to use const for immutability guarantees and constexpr to move computation to compile time, eliminating runtime overhead.
By the end of this page, you will understand the difference between const and constexpr, know when to reach for each one, and be able to write functions and variables that the compiler evaluates at compile time β saving runtime cycles and catching errors earlier.
What and Why
C++ gives you two related but distinct tools for expressing that something should not change:
const means "this value cannot be modified after initialization." The compiler enforces this at the point of use β any attempt to write through a const binding is a compile error. The value itself, however, may not be known until the program runs.
constexpr means "this value must be computable at compile time." It implies const, but goes further: the compiler is required to evaluate it before the program ever starts. If evaluation is impossible at compile time (because it depends on runtime input), the compiler rejects it outright.
The mental model: const is a promise to the reader and the compiler that a value won't change. constexpr is a promise that the compiler can compute the value itself, right now, during compilation.
Why does this matter? Compile-time constants:
- Eliminate magic numbers without adding runtime overhead.
- Enable array sizes, template arguments, and
if constexprbranches that are impossible with runtime values. - Catch logic errors earlier β at compile time rather than in production.
Step by Step
const β preventing modification
The simplest use is marking a local variable read-only:
#include <iostream>
int main() {
const int max_retries = 3;
// max_retries = 5; // error: assignment of read-only variable
std::cout << max_retries << '\n';
}const works on pointers too, and placement matters:
int value = 42;
const int* p1 = &value; // pointer to const int β *p1 is read-only
int* const p2 = &value; // const pointer to int β p2 cannot be reseated
const int* const p3 = &value; // both lockedRead pointer declarations right-to-left: int* const p2 is "p2 is a const pointer to int."
const on member functions
A member function marked const promises not to modify the object. This lets you call it on const instances:
#include <iostream>
#include <string>
class Config {
std::string host_;
int port_;
public:
Config(std::string h, int p) : host_(std::move(h)), port_(p) {}
const std::string& host() const { return host_; }
int port() const { return port_; }
void set_port(int p) { port_ = p; } // non-const: modifies state
};
void print(const Config& cfg) {
// cfg.set_port(80); // error: cfg is const
std::cout << cfg.host() << ':' << cfg.port() << '\n'; // fine
}
int main() {
Config c{"example.com", 443};
print(c);
}Mark every getter const. This unlocks your class for use in const contexts β const references, const containers, and thread-safe read paths.
constexpr variables β compile-time constants
Requires C++11.
#include <cstddef>
constexpr double pi = 3.14159265358979;
constexpr std::size_t buffer_size = 1024;
char buf[buffer_size]; // array size known at compile time β validCompare with plain const:
#include <iostream>
int main() {
int n;
std::cin >> n;
const int x = n; // fine: x is const but runtime-valued
// char arr[x]; // error: not a constant expression
// constexpr int y = n; // error: n isn't known at compile time
}constexpr functions β computation at compile time
A constexpr function is evaluated at compile time when all its arguments are constant expressions. Otherwise it falls back to a normal runtime call β you write it once and get both behaviors for free.
constexpr int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
constexpr int f6 = factorial(6); // computed at compile time: 720
int main() {
int runtime_n = 5;
int f5 = factorial(runtime_n); // computed at runtime β also valid
}constexpr in C++14 and later
C++11 constexpr functions were limited to a single return statement. C++14 relaxed this: you can use local variables, loops, and conditionals:
// C++14
constexpr int sum_to(int n) {
int total = 0;
for (int i = 1; i <= n; ++i)
total += i;
return total;
}
constexpr int s100 = sum_to(100); // 5050, computed at compile timeC++17 added if constexpr for template branching, and C++20 expanded constexpr to cover std::vector, std::string, and most of the standard library.
consteval β guaranteed compile-time functions (C++20)
Requires C++20.
A constexpr function may evaluate at compile time or at runtime depending on its arguments. consteval removes that ambiguity: every call must produce a compile-time constant, otherwise the program is ill-formed.
consteval int square(int n) { // C++20
return n * n;
}
constexpr int a = square(9); // OK: 9 is a constant β evaluates to 81 at compile time
int x = 9;
int b = square(x); // Error: x is not a constant expressionUse consteval when you need a hard guarantee β for example, a lookup table generator that must never silently fall back to runtime.
consteval implies inline (just like constexpr) but cannot be combined with constexpr on the same declaration, and cannot be applied to destructors or allocation/deallocation functions.
constinit β compile-time initialization without const (C++20)
Requires C++20.
constinit asserts that a static or thread_local variable is initialized at compile time, but does not make the variable immutable. The value can be changed after startup.
constexpr int square(int i) { return i * i; }
constinit int cache_size = square(16); // C++20: initialized to 256 at compile time
int main() {
cache_size = 512; // allowed β constinit does not imply const
}This solves half the static initialization order fiasco: a constinit global is guaranteed to be ready before any dynamic initialization runs, so other globals can safely depend on it.
Key distinctions:
constexprvariable β compile-time init + immutable at runtimeconstinitvariable β compile-time init + mutable at runtimeconstinitcannot be used together withconstexpr- Only applies to
staticandthread_localstorage duration (not local variables)
Common Patterns
Pattern 1: Named compile-time constants
Replace raw literals and #define with typed, scoped constexpr values:
namespace physics {
constexpr double speed_of_light = 2.998e8; // m/s
constexpr double planck = 6.626e-34; // JΒ·s
}
constexpr double photon_energy(double frequency) {
return physics::planck * frequency;
}
constexpr double visible_photon = photon_energy(6.0e14); // compile-timeUnlike #define, these are type-safe, respect namespaces, and show up in the debugger.
Pattern 2: constexpr for array sizes and template arguments
Template parameters and stack array sizes must be constant expressions. constexpr bridges readable names and these hard requirements:
constexpr int kCacheLineBytes = 64;
constexpr int kIntSlots = kCacheLineBytes / static_cast<int>(sizeof(int));
int aligned_buf[kIntSlots]; // valid: kIntSlots is constexpr
template<int N>
struct RingBuffer { int data[N]; };
RingBuffer<kIntSlots> ring; // valid: kIntSlots is a constant expressionPattern 3: const references as cheap, safe parameters
Pass large objects by const reference to skip the copy without risking mutation:
#include <numeric>
#include <vector>
double mean(const std::vector<double>& v) {
if (v.empty()) return 0.0;
return std::accumulate(v.begin(), v.end(), 0.0)
/ static_cast<double>(v.size());
}This is idiomatic C++ for any non-trivial type you read but do not modify.
What Can Go Wrong
Mistake 1: Calling a non-const member on a const object
class Counter {
int n_ = 0;
public:
void increment() { ++n_; }
int value() { return n_; } // forgot const!
};
void report(const Counter& c) {
// c.value(); // error: 'this' loses const qualifier
}Fix: mark value() as const:
int value() const { return n_; }Mistake 2: Expecting const to mean compile-time
#include <iostream>
int read_port() {
int p; std::cin >> p; return p;
}
int main() {
const int port = read_port(); // fine: const but runtime-valued
// template<int N> struct S {};
// S<port> s; // error: port is not a constant expression
}Fix: if you need a compile-time constant, use constexpr. If the value isn't known until runtime, you cannot use it as a template argument or array size β that's a fundamental constraint, not a compiler quirk.
Mistake 3: A constexpr function that cannot evaluate at compile time
#include <cstdlib>
constexpr int random_val() {
return std::rand(); // error: std::rand is not constexpr
}Fix: constexpr functions may only call other constexpr functions when used in a constant expression. Either drop constexpr or restructure the logic to use only compile-time-safe operations.
Mistake 4: Pointer const placement confusion
int a = 1, b = 2;
const int* p = &a;
p = &b; // fine: you can reseat the pointer
// *p = 3; // error: you cannot modify the value through pThe star divides the declaration: const to the left protects what is pointed to; const to the right protects the pointer itself. When in doubt, read the declaration right-to-left.
Quick Reference
| Feature | const | constexpr | consteval | constinit |
|---|---|---|---|---|
| Value/call immutable | Yes | Yes (implied) | Calls must be CT | No |
| Compile-time evaluation required | No | Yes (in CT contexts) | Always | Init only |
| May depend on runtime input | Yes | No | No | No (init) |
| Works on member functions | Yes | Yes | Yes | β |
| Works on variables | Yes | Yes | β | Yes (static/TLS) |
| Variable mutable at runtime | Yes | No | β | Yes |
| Enables template arguments / array sizes | No | Yes | β | No |
| Minimum standard | C++98 | C++11 | C++20 | C++20 |
| Loops and locals in function body | N/A | C++14+ | C++20+ | N/A |
Rules of thumb:
- Use
constwhen you want immutability but the value may come from runtime. - Use
constexprwhen you want β or require β compile-time evaluation. - Use
consteval(C++20) when a function must never run at runtime β make the guarantee explicit. - Use
constinit(C++20) when a global/static needs a compile-time initializer but must remain mutable. - Use
const&for function parameters to avoid copying large objects. - Mark every getter
const.
constexpr Containers in C++20
C++20 made std::vector and std::string fully constexpr, allowing you to build, sort, and inspect heap-allocated data structures entirely at compile time β the memory is allocated and freed within the constant-expression evaluator, never touching the runtime heap. This unlocks complex compile-time algorithms that previously required awkward fixed-size arrays or template metaprogramming tricks.
#include <vector>
#include <algorithm>
constexpr int find_max(std::initializer_list<int> values) {
std::vector<int> v(values); // heap allocation inside constexpr β C++20
std::sort(v.begin(), v.end());
return v.back();
}
constexpr int biggest = find_max({5, 1, 8, 3, 7}); // 8, computed at compile time
static_assert(biggest == 8);The key rule: any allocation performed inside a constant expression must be deallocated within the same constant expression. You cannot "leak" a compile-time allocation into a runtime constexpr variable β the evaluator enforces this. In practice this means constexpr std::vector<int> cannot be stored as a global constexpr variable (its memory cannot persist), but it can be used freely inside constexpr functions and then returned as a scalar result.
std::is_constant_evaluated() and if consteval
std::is_constant_evaluated() (C++20) and if consteval (C++23) both let a constexpr function take a different path depending on whether it is executing at compile time or at runtime β allowing you to use optimised runtime-only operations (like SIMD intrinsics or std::memcpy) when the compiler cannot see through them, while falling back to a portable constant-expression path during compile-time evaluation.
#include <cstring>
#include <type_traits>
// C++20 approach
constexpr int abs_value(int x) {
if (std::is_constant_evaluated()) {
// compile-time path: portable, no intrinsics
return x < 0 ? -x : x;
} else {
// runtime path: use built-in abs
return __builtin_abs(x);
}
}
constexpr int ct = abs_value(-7); // compile-time: uses first branch
int rt = abs_value(-7); // runtime: uses __builtin_absC++23 introduced if consteval as a more readable syntax that avoids the subtle pitfall of std::is_constant_evaluated() β the function itself must be consteval or constexpr, and the else branch is guaranteed to be skipped entirely during constant evaluation (not just branched over):
// C++23 approach β cleaner and harder to misuse
constexpr int abs_value_23(int x) {
if consteval {
return x < 0 ? -x : x; // compile-time only
} else {
return __builtin_abs(x); // runtime only
}
}A common mistake is calling std::is_constant_evaluated() as a run-time condition outside a constexpr function β it always returns false there and the check is useless. Wrap it inside a constexpr or consteval function.
constexpr Lambdas and Closures
Lambdas are implicitly constexpr in C++17 if their body satisfies constant-expression rules β you do not need to mark them explicitly, though you may do so for clarity. This means you can capture constexpr values and use lambdas in constexpr contexts without any extra syntax, making callback-style compile-time algorithms natural to write.
#include <array>
#include <algorithm>
constexpr auto square = [](int x) { return x * x; }; // implicitly constexpr
constexpr int s5 = square(5); // 25, evaluated at compile time
static_assert(s5 == 25);
// Lambdas in constexpr algorithms
constexpr std::array<int, 5> data = {3, 1, 4, 1, 5};
constexpr auto max_el = *std::max_element(data.begin(), data.end()); // 5
static_assert(max_el == 5);Captures work too, as long as what is captured is itself a constant expression:
constexpr int threshold = 3;
constexpr auto above_threshold = [threshold](int x) {
return x > threshold;
};
static_assert(above_threshold(5) == true);
static_assert(above_threshold(2) == false);You cannot capture a non-constexpr local by value in a constexpr lambda and then use the lambda in a constant expression β the captured value would be runtime-determined.
const and Thread Safety β Logical const with mutable
The C++ standard library treats const member functions as safe to call concurrently from multiple threads β a contract sometimes called "bitwise const" at the ABI level, but better described as "logically const": the function must not modify observable state without synchronisation. When a class has internal caches or lazy-computed values that are not part of its logical interface, you can declare those fields mutable and protect them with a std::mutex that is also mutable, satisfying both the const interface and the thread-safety requirement.
#include <mutex>
#include <string>
#include <optional>
class ExpensiveResult {
public:
explicit ExpensiveResult(std::string input) : input_(std::move(input)) {}
// Logically const: the visible result never changes
const std::string& compute() const {
std::lock_guard lock(mutex_);
if (!cache_) {
cache_ = slow_transform(input_); // computed once, lazily
}
return *cache_;
}
private:
std::string compute_impl() const;
static std::string slow_transform(const std::string& s) { return s + "_done"; }
std::string input_;
mutable std::optional<std::string> cache_;
mutable std::mutex mutex_;
};Two rules for safe logical-const design:
- Every
constmethod that readsmutablestate must hold the lock for the entire read. - The
mutablefields must not be observable through the public interface β callers should see a stable, pure value.
If you find yourself reaching for const_cast instead of mutable, that is usually a design smell: const_cast discards the compiler's enforcement, while mutable preserves it.
East const vs West const
The two placement styles for const β "west const" (traditional const int) and "east const" (int const) β are semantically identical for simple declarations, but east const is more consistent because it always modifies the token immediately to its left, matching how pointer declarations and function types compose. The difference only matters for pointers and references, where the east-const rule eliminates the need to memorise a separate pointer-reading rule.
// West const (traditional)
const int* p1; // pointer to const int
int* const p2 = &x; // const pointer to int
// East const (consistent rule: "const applies to what is on its left")
int const* p3; // pointer to const int β same as p1
int* const p4 = &x; // const pointer to int β same as p2For simple int-like types there is no difference and the choice is purely stylistic. East const advocates argue that reading types right-to-left becomes uniform: int const* const is "const pointer to const int" without needing a special rule for the leftmost const. Either style is acceptable β what matters is consistency within a codebase.
A practical note for templates and auto: east const makes type deduction easier to reason about because the const always sits next to the type it qualifies:
auto const x = compute(); // x is const, type deduced from compute()
int const& ref = x; // ref is a const reference to intWhat's Next
With const and constexpr solid, the natural next steps are:
- Const Correctness β a deeper look at propagating
constthrough an entire class interface and avoiding theconst_casttrap. if constexprβ use compile-time branching inside templates to select code paths cleanly, without SFINAE.- Advanced constexpr β
consteval,constinit, and compile-time containers introduced in C++20.