The Speaking Machine

Understanding const, constexpr in C++

const looks like one of the simplest keywords in C++ — it just means "this can't change," right? In practice it has surprising depth, especially once pointers, references, and compile-time evaluation enter the picture. This post walks through the concepts that actually matter, with small, self-contained examples, and ends with a few corner cases that trip up nearly everyone.

A const object must be initialized

Because a const object can never be assigned to after its declaration, it has exactly one chance to get a value: at initialization. So initialization is mandatory.

const int i = get_size(); // ok: initialized at runtime
const int j = 42;         // ok: initialized at compile time
const int k;              // error: uninitialized const object

Notice that "const" does not mean "known at compile time." i above is initialized from a function call whose result is only known at runtime — that is perfectly legal. const is a promise about mutability, not about when the value is known. (We'll see constexpr make the stronger compile-time promise later.)

Copying ignores const

A common worry is that const is "contagious" — that touching a const object contaminates everything around it. It doesn't. Copying reads the source and writes a brand-new, independent destination, so the source's constness is irrelevant:

int i = 42;
const int ci = i; // copy i into a const int; ci is independent of i
int j = ci;       // copy ci into a plain int; j is independent of ci

j is a perfectly ordinary, mutable int. The fact that it was initialized from a const object changes nothing about j itself. This intuition — that constness attaches to an object, not to the value flowing out of it — is the key to understanding "top-level vs. low-level const" later.

References to const

A reference is an alias for an existing object. A reference to const is an alias through which you promise not to modify the referent:

const int ci = 1024;
const int &r1 = ci; // ok: both reference and object are const
r1 = 42;            // error: cannot modify through a reference to const
int &r2 = ci;       // error: a plain int& would let you modify a const object

That last line is the important rule: you cannot bind a plain int& to a const object, because doing so would hand you a non-const door into a const object.

A reference to const can bind to things a plain reference cannot

Reference-to-const is more permissive about what it binds to. It can bind to a non-const object, to a literal, or even to the temporary result of an expression:

int i = 42;
const int &r1 = i;      // bind const ref to a plain (non-const) object
const int &r2 = 42;     // bind const ref to a literal
const int &r3 = r1 * 2; // bind const ref to the temporary result of an expression
int &r4 = r1 * 2;       // error: a non-const ref cannot bind to a temporary

Why the asymmetry? A plain int& promises you can write through it. Binding it to a temporary or literal would let you assign into something with no stable identity — nonsensical, so the language forbids it. A const int& makes no such promise, so it's safe, and the temporary's lifetime is even extended to match the reference.

"Reference to const" restricts the reference, not the object

This is one of the most-missed subtleties. A reference to const limits what you can do through that reference — it says nothing about whether the underlying object is const. The same object can be reachable through both a plain reference and a const reference at once:

int ival = 42;
int &r5 = ival;       // r5 can modify ival
const int &r6 = ival; // r6 refers to the same object but cannot modify it

r5 = 1024;            // ok: ival is now 1024
r6 = 2048;            // error: cannot modify through reference to const

After r5 = 1024, reading r6 yields 1024. r6 never promised the object was immutable — only that you wouldn't change it through r6. Someone else (here, r5) still can.

Pointers and const

Pointers add a second dimension, because there are now two things that could be const: the pointer itself, and the thing it points at.

Pointer to const

A pointer to const cannot be used to modify what it points at:

const double pi = 3.14;
double *ptr = π        // error: plain pointer to a const object
const double *cptr = π // ok: pointer to const
*cptr = 42;               // error: cannot modify through a pointer to const

Just like reference-to-const, this is a restriction on the pointer, not necessarily on the object: a pointer to const may legally point at a non-const object — it simply won't let you modify it through that pointer.

Const pointer

A const pointer is a pointer whose value (the address it holds) cannot change. You write the const after the *:

int err_no = 0;
int *const curr_err = &err_no; // curr_err will always point at err_no

You can still modify the pointed-to object — only the pointer is frozen:

*curr_err = 1;   // ok: modify err_no through the const pointer
if (*curr_err) {
    *curr_err = 0; // ok again: the object isn't const, only the pointer is
}

Const pointer to const

Combine both and you get a pointer that can neither be repointed nor used to modify its target:

const double pi = 3.14159;
const double *const pip = π
*pip = 2.72;       // error: cannot modify the pointed-to const double
pip = &something;  // error: cannot repoint a const pointer

A handy way to read these declarations is right to left: pip is a const pointer (*const) to a const double.

Top-level vs. low-level const

This single distinction explains most of the "why does this assignment compile but that one doesn't" puzzles.

int i = 0;
int *const p1 = &i;       // const is top-level (the pointer is const)
const int ci = 42;        // top-level (the int is const)
const int *p2 = &ci;      // low-level (points to const; pointer itself can change)
const int *const p3 = p2; // both: low-level (points to const) and top-level (const pointer)
const int &r = ci;        // const in a reference type is always low-level

The rules for copying then follow one principle:

When you copy an object, top-level const is ignored; low-level const must be respected.

Top-level const is ignored because copying creates an independent object — the source being immutable doesn't constrain the fresh copy:

i = ci;  // ok: top-level const on ci is irrelevant when copying its value
p2 = p3; // ok: p3's top-level (const pointer) is ignored; both point to const int

Low-level const, however, must be preserved or added, never silently dropped. You may convert non-const to const, but not the reverse:

p2 = &i;     // ok: int* -> const int*  (adding low-level const is fine)
int *p = p3; // error: const int* -> int* would discard low-level const

That asymmetry is a safety guarantee: dropping low-level const would let you modify something that was promised to be immutable.

const expressions and constexpr

A constant expression is one whose value can't change and can be evaluated at compile time. Whether something qualifies depends on both its type and its initializer:

const int max_files = 20;          // constant expression: const + literal initializer
const int limit = max_files + 1;   // constant expression: const + const expression
int staff_size = 27;               // NOT: not const, value can change at runtime
const int sz = get_size();         // NOT: const, but initialized at runtime

That last line is the gotcha: sz is const, yet it is not a constant expression, because get_size() is an ordinary runtime function. To make your intent explicit and have the compiler enforce compile-time evaluation, use constexpr:

constexpr int mf = 20;          // compile-time constant
constexpr int limit_cxp = mf+1; // compile-time constant
constexpr int sz_cxp = size();  // ok only if size() is a constexpr function

Declaring a variable constexpr tells the compiler "verify this is computable at compile time, and reject it otherwise." It's a stronger, checked promise than const.

A constexpr function must be defined before it is used

Here is a corner case worth its own section, because the error message is baffling the first time you see it.

A constexpr function can be evaluated at compile time — but only if the compiler can see its body at the point of use. A forward declaration is not enough:

constexpr int size();           // declaration only

constexpr int sz = size();      // error: must be initialized by a constant expression

constexpr int size() { return 1024; } // body comes too late

The compiler error reads something like:

error: constexpr variable 'sz' must be initialized by a constant expression

The fix is to ensure the full definition is visible before the call:

constexpr int size() { return 1024; } // define first

constexpr int sz = size();            // now ok

This is different from ordinary functions, where a declaration at the call site plus a definition in some other translation unit is fine — the linker resolves that later. But constant evaluation happens during compilation, before the linker runs, so "defined elsewhere" cannot work. This is precisely why constexpr helpers are typically defined in headers: a constexpr function is implicitly inline, so its definition can appear in every translation unit that includes the header without violating the One Definition Rule.

constexpr and pointers: the const lands on the pointer

A final subtlety. When you apply constexpr to a pointer, the constexpr qualifies the pointer, not the pointed-to type. In other words, constexpr imposes a top-level const:

constexpr int *q = nullptr; // q is a CONST POINTER to (plain) int

Read that carefully: q is not a "pointer to constexpr int" — it's a constexpr (hence const) pointer that happens to point to a plain int. To get const at both levels you must say so explicitly:

constexpr const int *pcxp = &i; // const pointer to const int
constexpr int *p1 = &j;         // const pointer to (non-const) int

There's also a lifetime requirement hiding here. A constexpr pointer's value must be a constant expression — a fixed address known at compile time. Addresses of local (automatic) variables aren't, because they live on the stack and differ each call. So the objects i and j above must have static lifetime (e.g. globals or static locals) for their addresses to be usable in a constexpr pointer.

Summary

#blog #const #constants #constexpr #cpp #pointers #programming #references