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.

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:

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:

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:

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:

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:

#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:

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:

int rejected = square(user_value);

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

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.