Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save MangaD/92c71b6a8c2dbab07d996d8461714792 to your computer and use it in GitHub Desktop.

Select an option

Save MangaD/92c71b6a8c2dbab07d996d8461714792 to your computer and use it in GitHub Desktop.
Top-Level vs Low-Level `const` Qualification in C++

Top-Level vs Low-Level const Qualification in C++

CC0

Disclaimer: ChatGPT generated document.

In C++, low-level and top-level const qualification describe where a const applies, especially with pointers.

Top-level qualification

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;  // OK

So int* const has top-level const.

Low-level qualification

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;  // ERROR

So in const int*, the const is low-level.

Both at once

const int* const p = &x;

Read it as:

p is 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 const

A good mental model is: top-level = the object itself; low-level = what you reach through the object.


Casting Away Low-Level const in C and C++

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.

C++

Use const_cast:

const int* p = /* ... */;
int* q = const_cast<int*>(p);

There are two cases.

Original object is not const: modification is valid

int value = 10;

const int* p = &value;
int* q = const_cast<int*>(p);

*q = 20;  // Valid

value was originally defined as a mutable int. It was merely accessed temporarily through a pointer-to-const.

Original object is const: modification is undefined behavior

const int value = 10;

const int* p = &value;
int* q = const_cast<int*>(p);

*q = 20;  // Undefined behavior

Creating 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);  // Error

A 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

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 const

But:

const int value = 10;
const int *p = &value;
int *q = (int *)p;

*q = 20;  // Undefined behavior

Important distinction

“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 behavior

So 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.


What Happens in Practice When You Modify a const Object

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

Why can that happen?

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.

Another possibility: crash

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;

And optimization changes everything

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.

Contrast with the safe case

This distinction is important:

int x = 10;

const int* cp = &x;
int* p = const_cast<int*>(cp);

*p = 20;  // perfectly valid

Here the actual object is:

int x

not:

const int x

The 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment