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.
- Top-level const means the object itself is const. A const
int, or a const pointer (int *const), has top-level const. - Low-level const appears in the base type a compound type refers to — e.g. a
pointer/reference to const (
const int *). It's about the thing pointed or referred to.
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
constis a promise about mutability, not about when a value is known.- Copying ignores the source's constness; the copy is an independent object.
- "Reference/pointer to const" restricts what you can do through that reference/pointer — the underlying object may still be mutable elsewhere.
- A const pointer (
T *const) freezes the address; a pointer to const (const T *) freezes the target. - Top-level const (the object itself) is dropped when copying; low-level const (the pointed/referred-to type) must be preserved — you may add it, never silently discard it.
constexpris a stronger, compiler-checked promise of compile-time evaluation; not everyconstis a constant expression.- A
constexprfunction must be defined (body visible) before it's used in a constant expression — which is why such functions live in headers. constexpron a pointer is top-level const, and the pointer must point at an object with static lifetime.