C++ const, constexpr and consteval: Runtime Immutability vs Compile-Time Guarantees

See exactly what each C++ keyword guarantees. One C++20 program separates immutability, optional constant evaluation and mandatory constant evaluation.

KnowledgeGate Team

Exam prep & CS education

Updated 5 Sep 20266 min read

const, constexpr and consteval all look like different ways to say "constant", but they make different promises. One blocks mutation through an object, one makes a value or function available to constant-expression contexts, and one rejects ordinary runtime calls. A C++20 program demonstrates the distinction: it outputs exactly 6 8 14 81, and two deliberately invalid lines reveal the boundaries. The broader Coding & Skill Development category connects this topic to related programming courses and tutorials.

Related reading: C++ overload resolution and C++ iterator rules.

Three keywords, three different contracts

Think in contracts before thinking in syntax. const asks, "May this object be modified through this name?" A constexpr object must have a constant-expression initializer. A constexpr function can be evaluated at compile time when its arguments and the surrounding context qualify, but it can also serve a runtime caller. A consteval function is immediate, so each ordinary call must succeed as a constant expression. A constexpr object is also const, but an arbitrary const object need not be a constant expression.

In our program, runtime_limit becomes 6 without a compile-time guarantee. double_it(4) supplies the compile-time array size 8, while double_it(user_value) produces 14 in an allowed runtime call when user_value is 7. square(9) produces 81, but square(user_value) is rejected. WG21's P1073R3: Immediate functions defines the immediate-function model and explains why constexpr does not force every call to be evaluated during translation. If these declarations are new, the C++ Tutorial gives a wider learning path.

A three-column figure: const gives runtime_limit 6, constexpr gives table_size 8 and run_result 14, consteval gives token 81.

const: immutable after initialisation does not mean compile-time

Start with an ordinary function and a const object:

cpp
int load_limit() { return 6; }
const int runtime_limit = load_limit();

The observed value is 6, and runtime_limit = 9; is rejected because the object cannot be assigned after initialisation. However, static_assert(runtime_limit == 6); is ill-formed. The initializer calls a non-constexpr function, so runtime_limit is not usable as a constant expression. The missing property is constant-expression eligibility, not immutability.

Do not turn that into the rule "const is always runtime". An integral declaration such as const int literal_limit = 6; has a constant-expression initializer and can be usable in a constant-expression context. The accurate rule is that const alone does not guarantee compile-time evaluation.

Pointer declarations show why you must identify what is const. With int a = 5, b = 8;, const int* p = &a; permits p = &b; but rejects *p = 9;: the pointed-to integer is protected through p. By contrast, int* const q = &a; permits *q = 9; but rejects q = &b;: the pointer itself cannot be redirected.

constexpr: the call is compile-time only when the context requires it

Consider one function:

cpp
constexpr int double_it(int x) { return 2 * x; }

Now use it in three contexts that require constant expressions. constexpr int table_size = double_it(4); makes table_size exactly 8. static_assert(double_it(5) == 10); must be checked during translation. Finally, std::array<int, double_it(4)> table{}; has exactly 8 elements because its non-type template argument must be a constant expression.

The same function remains valid here:

cpp
int user_value = 7;
int run_result = double_it(user_value);

This call produces 14 at runtime. It is not a constant expression because it reads a non-const runtime object, but a constexpr function is allowed to serve that caller. A compiler may still optimise the multiplication; such folding is not a language guarantee supplied by constexpr.

The distinction is between object and function contracts. A constexpr variable is const and must be initialised by a constant expression. A constexpr function can support both compile-time and runtime calls. This section's code is valid in C++17. For adjacent class and object concepts, see Object Oriented Technology Explained, not as evidence for constant-expression rules but as the next conceptual layer in C++.

consteval: C++20 turns failed constant evaluation into a compile error

C++20 adds an immediate function declaration:

cpp
consteval int square(int x) { return x * x; }

constexpr int token = square(9); is valid and fixes token at 81. Even int also_ok = square(10); is valid. The destination is not constexpr, but the call itself has the constant argument 10, so square(10) must be evaluated as a constant expression and yields 100.

The boundary appears with int user_value = 7; int rejected = square(user_value);. This is ill-formed because an immediate call cannot read that non-constant object. An optimiser noticing that it currently contains 7 does not change the language rule.

The version boundary is equally clear. consteval is absent from C++17 and part of C++20. Compile the full example as C++20. For a C++17 teaching variant, remove square, token, and the fourth printed value; the const and constexpr demonstrations remain.

Worked example: compile one file, then expose both rejected lines

Use this exact positive C++20 program:

cpp
#include <array>
#include <iostream>

int load_limit() { return 6; }

constexpr int double_it(int x) { return 2 * x; }

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

int main() {
  const int runtime_limit = load_limit();
  constexpr int table_size = double_it(4);
  std::array<int, table_size> table{};

  int user_value = 7;
  int run_result = double_it(user_value);
  constexpr int token = square(9);

  static_assert(double_it(5) == 10);

  std::cout << runtime_limit << ' '
            << table.size() << ' '
            << run_result << ' '
            << token << '\n';
}

The output is:

Code
6 8 14 81

Trace each number instead of memorising the line. load_limit() returns 6, which initialises an immutable object through an ordinary function. double_it(4) computes 2 * 4 = 8; that result becomes both table_size and the std::array size. The runtime call computes double_it(7) = 2 * 7 = 14. The immediate call computes square(9) = 9 * 9 = 81. The array declaration supplies a concrete check: without a constant-expression table_size, its template argument would be rejected. The positive file compiles cleanly as C++20. That confirms the language uses without making any claim about optimiser behaviour.

Next add this line inside main, compile, then remove it:

cpp
int rejected = square(user_value);

It must fail because user_value is not a constant expression. Then test the second line separately:

cpp
static_assert(runtime_limit == 6);

It must fail because the call to load_limit() was not a constant expression. Testing one rejected line at a time keeps each diagnostic tied to one rule. The C++17 variant omits the consteval declaration and use, compiles the remaining program as C++17, and prints 6 8 14.

Evaluation trace: load_limit gives 6, double_it gives 8 and 14, square gives 81, printing 6 8 14 81.

Common traps and how checks expose them

Trap

Correction from the program

Every const value is compile-time

runtime_limit is immutable, but its ordinary function initializer prevents constant-expression use.

Every constexpr call runs at compile time

double_it(user_value) is allowed at runtime and returns 14.

Optimiser folding proves a language guarantee

Optimisation does not turn a non-constant expression into a guaranteed constant expression.

consteval works in a C++17 build

The square declaration requires C++20.

constinit concerns static initialisation. It is neither general immutability nor a rule requiring every later call to be constant-evaluated.

Use these checks to test your explanation:

  1. Why does std::array<int, table_size> compile with size 8?

  2. Why may double_it(user_value) return 14?

  3. Why does square(user_value) fail when user_value currently stores 7?

  4. Why is static_assert(runtime_limit == 6) invalid although the program later prints 6?

Each answer should name the constant-expression context and the relevant declaration contract, not claim that "the compiler is smart enough". Also test the version: the unmodified file is valid under C++20, while C++17 rejects the consteval keyword.

The short version and the next step

  • Use const when mutation must be blocked, as with runtime_limit = 6.

  • Use constexpr when a value or reusable function should be available to constant-expression contexts. That covers table_size = 8 while still allowing run_result = 14.

  • Use consteval in C++20 when an ordinary direct call must be rejected unless it can be evaluated as a constant expression, as with token = 81.

Now change user_value from 7 to 11. Predict double_it(11) = 22, then explain why square(user_value) remains invalid. Continue with the C++ Programming Course for the direct structured route, or use Coding for Placements for a broader multi-language route.