Disclaimer: ChatGPT generated document.
In C++, low-level and top-level const qualification describe where a const applies, especially with pointers.
Top-level const applies to the object itself.
const int x = 10;Here, x itself is const, so const is top-level.
For a pointer:
int x = 10;
int* const p = &x;p itself is const. You can't make p point somewhere else:
p = &y; // ERROR
*p = 20; // OKSo int* const has top-level const.
Low-level const applies to something the object points or refers to.
const int* p;p is an ordinary pointer, but it points to a const int.
int x = 10;
int y = 20;
const int* p = &x;
p = &y; // OK
*p = 30; // ERRORSo in const int*, the const is low-level.
const int* const p = &x;Read it as:
pis a const pointer to a const int.
There are therefore two consts:
const int * const p
^^^^^ ^^^^^
low-level top-level
A useful comparison is:
| Type | Meaning | Qualification |
|---|---|---|
int |
integer | none |
const int |
const integer | top-level |
int* |
pointer to int | none |
const int* |
pointer to const int | low-level |
int* const |
const pointer to int | top-level |
const int* const |
const pointer to const int | both |
The distinction becomes particularly important in copying, assignment, function parameters, and type deduction. For example, C++ can often ignore top-level const when copying a value, while low-level const generally cannot simply be discarded:
const int x = 10;
int y = x; // OK: top-level const doesn't matter for the copy
const int* p = &x;
int* q = p; // ERROR: would discard low-level constA good mental model is: top-level = the object itself; low-level = what you reach through the object.
Both C and C++ let you explicitly remove a low-level const qualifier. The cast itself is not necessarily undefined behavior. Undefined behavior occurs when you use the resulting pointer to modify an object that was actually defined as const.
Use const_cast:
const int* p = /* ... */;
int* q = const_cast<int*>(p);There are two cases.
int value = 10;
const int* p = &value;
int* q = const_cast<int*>(p);
*q = 20; // Validvalue was originally defined as a mutable int. It was merely accessed temporarily through a pointer-to-const.
const int value = 10;
const int* p = &value;
int* q = const_cast<int*>(p);
*q = 20; // Undefined behaviorCreating q is allowed. The undefined behavior happens when *q = 20 attempts to modify the genuinely const object. The C++ standard explicitly gives this distinction. (Eel)
Other C++ casts cannot directly cast away constness:
int* q = static_cast<int*>(p); // Error
int* q = reinterpret_cast<int*>(p); // ErrorA C-style cast may succeed because it can perform a const_cast internally:
int* q = (int*)p;However, const_cast is preferable because it clearly announces that constness is being removed.
C uses an explicit cast:
const int *p = /* ... */;
int *q = (int *)p;The same underlying rule applies:
int value = 10;
const int *p = &value;
int *q = (int *)p;
*q = 20; // Valid: value was not defined as constBut:
const int value = 10;
const int *p = &value;
int *q = (int *)p;
*q = 20; // Undefined behavior“Casting away const” and “modifying a const object” are separate operations:
const int value = 10;
int* p = const_cast<int*>(&value); // Allowed
int x = *p; // Allowed: only reading
*p = 20; // Undefined behaviorSo the rule is:
You may form a non-const pointer to a const-qualified object, but you may modify through it only when the underlying object was not actually defined as const.
volatile is stricter in practice: casting it away and then accessing the object through a non-volatile path can itself produce undefined behavior, not merely when writing.
Typically, one of several things happens—and this is exactly why the standard calls it undefined behavior rather than simply saying “the write is ignored.”
Consider:
const int x = 10;
int* p = const_cast<int*>(&x);
*p = 20;
std::cout << x << '\n';
std::cout << *p << '\n';A natural expectation is:
20
20
But you may get something surprising such as:
10
20
The compiler knows:
const int x = 10;and is allowed to assume that x never changes through a valid C++ program.
So when it sees:
std::cout << x;it may effectively generate code equivalent to:
std::cout << 10;Meanwhile:
std::cout << *p;may actually read the memory location where your illegal write stored 20.
Conceptually:
compiler's knowledge
x = 10
│
▼
cout << x ────────> 10
Memory:
x's address
│
▼
┌────────┐
│ 20 │ ← *p = 20 changed memory
└────────┘
▲
│
cout << *p ────────> 20
This is one of the classic consequences of UB: the abstract C++ value and what happens to be sitting in memory no longer need to agree in the way you expect.
For some const objects:
const int table[] = {1, 2, 3};
int* p = const_cast<int*>(table);
p[0] = 100;the compiler/linker may place table in a read-only memory section.
Then the CPU/OS may reject the write, resulting in something like a segmentation fault/access violation.
This is especially common with static-storage objects that the compiler can put into read-only sections:
static const int x = 10;You might test:
const int x = 10;
int* p = const_cast<int*>(&x);
*p = 20;
printf("%d\n", x);
printf("%d\n", *p);and observe at -O0:
10
20
Then compile with -O2 and see different behavior.
Or a slightly different program might appear to give:
20
20
That's not a contradiction. Once the program executes undefined behavior, the language places essentially no requirements on the resulting execution.
This distinction is important:
int x = 10;
const int* cp = &x;
int* p = const_cast<int*>(cp);
*p = 20; // perfectly validHere the actual object is:
int xnot:
const int xThe const was only introduced by the pointer:
int x
↑
│
const int*
Removing that low-level qualifier restores access to the original mutable object.
So the key question isn't really:
“Did I cast away
const?”
It's:
“Was the underlying object originally defined as const?”
If no, modifying it after const_cast can be completely valid.
If yes, attempting to modify it is UB, and in real programs you'll commonly see the write apparently work, see inconsistent/stale values because of optimization, or get a hardware/OS fault if the object lives in read-only memory.
