Const Pointers

Hi everyone ✋

In the previous post we peeled the label off our pointers and met the mysterious, typeless void pointer 📦 ─ a pointer that can hold the address of anything, but knows the type of nothing.

If we haven’t read the earlier posts in this series, it’s worth going through them first ─ today we lean on almost everything we’ve built so far, especially the intro, arrays, and strings posts.

Today’s topic is const pointers ─ what happens when we start locking things down with const, and how we untangle that wonderfully confusing difference between a pointer to a constant and a constant pointer 🤔

It has a reputation for being scary. People see a declaration like const int * const ptr and their eyes glaze over 😵‍💫. But by the end of this post, that line will read as easily as a sentence. There’s no magic here ─ just two little locks, and once we know which lock is which, the whole thing falls apart into something almost boring.

So let’s take a deep dive 🦴

A quick refresher ─ what does const even mean? 🤔

Before we touch pointers, let’s remember the plain, everyday const.

When we put const in front of a variable, we’re telling the compiler “this value is frozen ─ nobody gets to change it after we set it.”

const int a = 5;

a = 10;   // ERROR ─ won't compile! 😱 a is frozen

The compiler flatly refuses. a was born as 5 and it will die as 5. That’s all const is ─ a promise, enforced by the compiler, that “this thing will not change.”

Simple, right? Now here’s the twist that makes pointers interesting 👇

The big idea ─ a pointer has two things worth freezing 🔒🔒

Every other variable has just one thing we could freeze ─ its value. But a pointer is special. Remember from way back in the intro post that a pointer is a variable holding an address. So a pointer actually has two separate things going on ─

  1. The address it holdswhich box it’s pointing at.
  2. The value at that addresswhat’s inside the box it’s pointing at.

And here’s the key insight of this entire post ─ we can slap a const lock on either one of them, independently. That gives us a little menu of combinations ─

  • Lock neither → the ordinary pointer we already know.
  • Lock the value (but not the address) → a pointer to a constant.
  • Lock the address (but not the value) → a constant pointer.
  • Lock both → a constant pointer to a constant.

That’s the whole topic. Four combinations. Two locks. That’s it 🥳

Everything confusing about const and pointers comes from mixing up which lock is being applied. So let’s set up a mental picture that makes the two locks impossible to confuse.

My little reference ─ the TV remote and the row of TVs 📺

Here’s the picture to keep in mind for the whole post.

Picture this ─ we’re holding a TV remote. That remote is our pointer. In front of us is a row of TVs on the wall ─ each TV is a variable sitting in memory.

The remote can do exactly two things ─

  • Aim at a TV. We can point the remote at TV #1, then swing it over and point it at TV #2. This is reassigning the pointerptr = &something_else. We’re changing which box it points at.
  • Press the buttons to change the channel. Once it’s aimed at a TV, we can hit the buttons and change what’s playing on that screen. This is modifying the value through the pointer*ptr = something. We’re changing what’s inside the box.

Now watch how our two const locks map perfectly onto this remote ─

  • Locking the value = the channel buttons are disabled 🔇. We can still aim the remote wherever we like, but we can only watch ─ we can’t change what’s on the screen.
  • Locking the address = the remote is bolted to a stand facing one single TV 🔩. The buttons still work fine, but the remote can never turn to face a different TV.

Keep this remote in mind. Every single line of code below is just “which of these two abilities did we take away?” 😉

Let’s go through all four cases, one at a time.


Case 0 ─ the plain pointer (nothing locked) 🎮

We’ve been using this one for the whole series, but let’s look at it fresh, because it’s our baseline. No const anywhere ─ the remote can aim and press buttons freely.

#include <iostream>
using namespace std;

int main()
{
    int a = 5, b = 15;

    int *ptr = &a;   // an ordinary pointer

    *ptr = 100;      // ✅ press the buttons ─ change a's value
    ptr  = &b;       // ✅ aim elsewhere  ─ now point at b

    cout << "a = " << a << ", *ptr = " << *ptr << endl;

    return 0;
}

The output will be ─

a = 100, *ptr = 15

Both moves were legal ─ we changed a through the pointer, then swung the pointer over to b. This is our free-moving remote with working buttons. Now let’s start taking abilities away 🔒

Case 1 ─ pointer to a constant (const int *) ─ the buttons are locked 🔇

This is the one we meet first, and the one most often misread. Let’s write it ─

#include <iostream>
using namespace std;

int main()
{
    int a = 5, b = 15;

    const int *ptr = &a;   // pointer TO a const int

    // *ptr = 100;   // ❌ ERROR ─ buttons locked, can't change the value
    ptr = &b;        // ✅ but we CAN aim at a different TV

    cout << "*ptr = " << *ptr << endl;

    return 0;
}

The output will be ─

*ptr = 15

Let’s read the declaration out loud ─ const int *ptr is “a pointer to a const int.” The thing being frozen is the int, the value the pointer looks at. So through this pointer, the value is read-only ─ the channel buttons are disabled 🔇.

But notice ─ the pointer itself is not frozen. We happily swung it from a over to b. The remote can still aim anywhere; it just can’t press buttons.

If we uncomment that *ptr = 100; line, the compiler stops us cold ─

error: assignment of read-only location '*ptr'

There’s a subtle, lovely detail here 🤔. Look ─ a itself was a perfectly ordinary, non-const int. We can still change a directly (a = 100; is totally fine), or through an ordinary int*. The const in const int *ptr doesn’t freeze a forever ─ it only means we may not change it through this particular pointer. This pointer took a personal vow of “look, don’t touch.” That’s the essence of a pointer-to-const, and it’s exactly what makes it so useful for function parameters, as we’ll see later 💪

Case 2 ─ constant pointer (int * const) ─ bolted to one TV 🔩

Now let’s flip which lock we use. This time we freeze the address, not the value.

#include <iostream>
using namespace std;

int main()
{
    int a = 5, b = 15;

    int * const ptr = &a;   // a CONST pointer to int

    *ptr = 100;      // ✅ buttons work ─ we CAN change a's value
    // ptr = &b;     // ❌ ERROR ─ bolted down, can't aim elsewhere

    cout << "a = " << a << endl;

    return 0;
}

The output will be ─

a = 100

Let’s read it out loud ─ int * const ptr is “a const pointer to an int.” The const sits right next to ptr, so it’s the pointer that’s frozen ─ the remote is bolted to a stand facing a forever 🔩. It will never aim at another TV.

But the value? Fully editable. We changed a to 100 right through it. The buttons work perfectly; the remote just can’t turn.

If we uncomment ptr = &b;, the compiler says ─

error: assignment of read-only variable 'ptr'

⚠️ There’s a rule hiding here that trips up beginners ─ a constant pointer must be initialized the moment we declare it. Think about it ─ if the remote gets bolted down permanently, we’d better aim it at a TV before we tighten the bolt! We can’t declare it now and point it somewhere later, because “later” would be reassignment, and reassignment is exactly what’s forbidden.

int * const ptr;   // ❌ ERROR ─ uninitialized const pointer
error: uninitialized 'const ptr'

(If this reminds you of a reference, good instinct ─ a reference is essentially a const pointer under the hood, bound once and never re-aimed. We saw references all the way back in the pass by value and pass by reference post 😉)

Case 3 ─ constant pointer to a constant (const int * const) ─ both locked 🔩🔇

Now we throw both locks. The remote is bolted to one TV and the buttons are dead. It’s a screen we can only ever stare at.

#include <iostream>
using namespace std;

int main()
{
    int a = 5, b = 15;

    const int * const ptr = &a;   // const pointer to const int

    // *ptr = 100;   // ❌ ERROR ─ buttons locked
    // ptr  = &b;    // ❌ ERROR ─ bolted down

    cout << "*ptr = " << *ptr << endl;   // ✅ looking is always allowed

    return 0;
}

The output will be ─

*ptr = 5

Both abilities are gone. We can’t change the value, and we can’t aim elsewhere. The only thing left is reading ─ cout << *ptr ─ because looking at the screen never hurt anyone 👀. This is the most locked-down a pointer can be, and it’s exactly what we reach for when we want to say “this points at one thing, forever, and we never touch it.”


The reading trick ─ read right to left 👀

Okay, we’ve seen all four cases. But how do we read a scary declaration without memorising a table? There’s one beautiful rule that never fails ─ whenever you meet one of these, read the declaration from right to left, and let each const freeze whatever is immediately to its left.

Let’s practice on the two tricky ones ─

  • int * const ptr → reading right-to-left: ptr is a const* (pointer) … to int.”a const pointer to an int. The const is left of ptr‘s position, next to the *, so it freezes the pointer. Bolted remote 🔩.
  • const int * ptr → reading right-to-left: ptr is a * (pointer) … to int … that is const.”a pointer to a const int. The const freezes the value. Locked buttons 🔇.

The mental shortcut ─ whatever the const is touching, that’s what gets frozen. Is const hugging the *? The pointer is frozen. Is const hugging the int? The value is frozen.

There’s one more thing that confuses people, and the right-to-left rule clears it up instantly ─ these two lines are exactly the same

const int *ptr;   // pointer to const int
int const *ptr;   // ALSO a pointer to const int ─ identical!

Both put const on the int (the value), just in a different spot. The compiler treats them as twins. Some seasoned C++ folks actually prefer the second style (int const *) precisely because the right-to-left rule reads so cleanly with it. Either is fine ─ we just pick one and stay consistent 🥳

Here’s the whole menu in one table, using our remote to keep it grounded ─

Declaration What’s frozen Can change value? *ptr = x Can re-aim? ptr = &y Our remote
int *ptr nothing ✅ yes ✅ yes free remote, working buttons 🎮
const int *ptr the value ❌ no ✅ yes free remote, dead buttons 🔇
int * const ptr the pointer ✅ yes ❌ no bolted remote, working buttons 🔩
const int * const ptr both ❌ no ❌ no bolted remote, dead buttons 🔩🔇

Keep this table in mind and a const pointer never has to scare us again 😄

One more rule the compiler quietly enforces ⚠️

There’s a safety rule that surprises people, and it’s worth its own moment. We cannot aim an ordinary pointer at a const variable.

const int a = 5;

int *ptr = &a;   // ❌ ERROR ─ won't compile! 😱
error: invalid conversion from 'const int*' to 'int*'

Why the refusal? Let’s think it through with the remote 🤔. a is a frozen value ─ a TV whose channel is supposed to be uneditable. But int *ptr is a remote with working buttons. If the compiler let that assignment through, we could then do *ptr = 100; and quietly change a ─ blowing straight through the const promise we made! So C++ blocks it at the door.

The fix is to use a remote whose buttons are also locked ─ a pointer-to-const ─

const int a = 5;

const int *ptr = &a;   // ✅ fine ─ both agree the value is frozen

Notice the direction of the rule ─ we’re always allowed to add a lock (point a const int* at a plain int, promising extra restraint), but we can never remove one (point a plain int* at a const int, which would break a promise). Locks only tighten, never loosen ─ at least, not without a special tool we’ll meet at the very end 😉


Where const pointers really earn their keep 🛠️

Alright ─ so far this might feel like the compiler inventing rules just to say “no” 😅. So let’s see why every serious C++ codebase is absolutely soaked in const. This is the part that really matters.

Real problem 1 ─ read-only function parameters (const correctness) 🏆

This is the big one. Remember from the pointers and functions post that when we pass a pointer into a function, that function can reach right back into our data and change it. Sometimes we want that. But very often, a function only needs to read our data ─ a function that prints an array, or sums it, has no business modifying it.

So how do we promise the caller “we’ll only look, we won’t touch”? We take the parameter as a pointer to const 🥳

#include <iostream>
using namespace std;

// the const promises we only read the array here, never modify it
int sumArray(const int *arr, int n)
{
    int total = 0;
    for (int i = 0; i < n; i++)
        total += arr[i];   // ✅ reading ─ perfectly allowed

    // arr[0] = 99;        // ❌ ERROR ─ we promised not to touch!

    return total;
}

int main()
{
    int nums[] = { 10, 20, 30 };

    cout << "sum = " << sumArray(nums, 3) << endl;

    return 0;
}

The output will be ─

sum = 60

This little const does three wonderful things at once ─

  1. It documents intent. Anyone reading the signature sumArray(const int *arr, ...) instantly knows this function is a reader, not a writer. The promise is right there in the type 📄.
  2. It’s enforced by the compiler. If we (or a teammate six months from now) accidentally write arr[0] = 99 inside, the build fails. The promise can’t be broken by mistake 🛡️.
  3. It accepts more callers. A const int* parameter can accept both const and non-const arrays, so the function is usable in more places.

This idea has a name ─ const correctness ─ and it’s considered a hallmark of good C++. It’s exactly why the standard library is full of signatures like strlen(const char *str) and strcmp(const char *a, const char *b). Those functions read our string; they’d never dream of scribbling on it, and the const in their signatures says so, out loud, forever.

Real problem 2 ─ string literals are secretly const 📜

Here’s a const pointer we’ve been using without realising it ─ ever since the strings post!

When we write a string literal like "hello", that text lives in a read-only region of memory. So the correct type for a pointer to it is const char *

const char *msg = "hello";   // ✅ the right, honest type

cout << msg << endl;         // reading is fine

The output will be ─

hello

Why const? Because a string literal is frozen ─ trying to modify it (msg[0] = 'H';) is undefined behaviour, the crash-or-corruption territory we keep warning about ☠️. Older C code got away with char *msg = "hello"; for historical reasons, but modern C++ nudges us toward const char* precisely so the compiler can stop us from writing to something we were never allowed to touch. The const is protecting us 🛡️.

Real problem 3 ─ the this pointer inside const member functions 🎯

This one’s a lovely tie-in, because it connects const pointers to objects.

If we’ve peeked at classes, we know every member function secretly receives a hidden pointer called this, pointing at the object the method was called on. Now ─ what happens when we mark a member function as const?

#include <iostream>
using namespace std;

class Box
{
    int value = 42;

public:
    // note the 'const' after the parentheses 👇
    void show() const
    {
        cout << "value = " << value << endl;   // ✅ reading is fine
        // value = 100;   // ❌ ERROR ─ can't modify in a const method
    }
};

int main()
{
    Box b;
    b.show();
    return 0;
}

The output will be ─

value = 42

That little const after show() does something beautiful ─ inside a const member function, the hidden this pointer becomes a pointer to a const object (const Box*). Which means every member is now read-only for the duration of that function 🔇. The method is promising “calling me will not change this object.”

So the entire “look, don’t touch” idea from Case 1 scales up from a single int all the way to a whole object. A const member function is just Case 1 applied to this. See how the series keeps clicking together? 🥳

Real problem 4 ─ self-documenting, mistake-proof code 🧠

Step back and notice the pattern across all three examples. In every case, const is doing the same two jobs ─ it tells a human what’s allowed, and it forces the compiler to hold everyone to it.

That’s a rare combination. Most documentation lies eventually ─ comments drift out of date, README files rot. But a const in a type signature is documentation the compiler checks on every single build. It can never become a lie. Seasoned C++ developers reach for const by default and only remove it when they truly need to mutate ─ a habit sometimes called “const by default.” It turns whole categories of bugs into compile errors, which is the best place for a bug to die 💪


The escape hatch ─ const_cast (handle with care) ☠️

We noted earlier that locks only tighten, never loosen ─ “at least, not without a special tool.” Here’s that tool.

C++ gives us const_cast, which can strip the const off a pointer ─

#include <iostream>
using namespace std;

int main()
{
    int a = 5;              // a is NOT really const

    const int *cp = &a;     // we're viewing it through a locked-button remote
    // *cp = 100;           // ❌ ERROR ─ buttons locked

    int *mp = const_cast<int*>(cp);   // 🔓 forcibly remove the lock
    *mp = 100;              // now allowed

    cout << "a = " << a << endl;

    return 0;
}

The output will be ─

a = 100

⚠️ But here be dragons. This example was safe only because a was never truly const in the first place ─ we were just looking at an ordinary variable through a const pointer, and prying the lock off did no real harm.

If we const_cast away the const from something that was genuinely const (a real const int a = 5;) and then write to it, we get undefined behaviour ─ the same crash-or-corruption gremlin from the dynamic memory post 😱. We’d be breaking a promise the compiler and the hardware may have counted on (the value could be sitting in genuinely read-only memory).

So the honest guidance ─ const_cast exists mostly for talking to grumpy old APIs that forgot to mark their parameters const. In our own code, if we find ourselves reaching for const_cast to modify something, that’s usually a sign the const shouldn’t have been there in the first place. Fix the design, don’t pick the lock 😉

Gotchas to keep us safe ⚠️

Let’s gather every tripwire in one place ─

  • const int * freezes the value; int * const freezes the pointer. Mix these up and everything else falls apart. When in doubt, read right to left and see what the const is hugging.
  • const int * and int const * are identical. Same meaning, different spelling ─ both freeze the value. Don’t let the swapped order fool us.
  • A const pointer must be initialized at declaration. We bolt the remote after aiming it, never before. int * const ptr; alone won’t compile.
  • We can’t aim a plain int* at a const int. That would let us break the const promise, so the compiler blocks it. Use const int* instead ─ locks only tighten.
  • A pointer-to-const doesn’t make the variable const. It only means we can’t change it through this pointer. The original variable may still be changed directly or through a non-const pointer.
  • String literals want const char *. Writing through a char* to a literal is undefined behaviour ☠️.
  • const_cast + writing to a truly-const object = undefined behaviour. The escape hatch is for legacy APIs, not for defeating our own const.
  • const after a member function (void f() const) makes this a pointer-to-const. The whole object goes read-only inside that method.

Follow these and const becomes a friendly guard-rail. Ignore them and we’ll either fight the compiler needlessly or, worse, pick a lock we shouldn’t have 😅

So what should we remember? 🤔

Let’s wrap up the key takeaways ─

  • A pointer has two freezable things ─ the address it holds (which box) and the value at that address (what’s inside). const can lock either one independently.
  • Picture the TV remote 📺 ─ aiming it at a TV is reassigning the pointer (ptr = &x); pressing the buttons is modifying the value (*ptr = x).
  • The four combinations ─
    • int *ptr ─ nothing locked. Free remote, working buttons 🎮.
    • const int *ptrpointer to a constant. Value frozen, pointer free. Dead buttons, free remote 🔇.
    • int * const ptrconstant pointer. Pointer frozen, value free. Bolted remote, working buttons 🔩.
    • const int * const ptrconstant pointer to a constant. Both frozen. Bolted remote, dead buttons 🔩🔇.
  • Read declarations right to left, and whatever the const is hugging is what gets frozen ─ next to * freezes the pointer, next to the type freezes the value.
  • const int * and int const * mean exactly the same thing.
  • A constant pointer must be initialized at declaration (just like a reference ─ bind once, never re-aim).
  • We can’t point a plain int* at a const variable ─ locks only tighten, never loosen.
  • Its big purpose is const correctness ─ read-only function parameters (const int *arr), which document intent and are enforced by the compiler. This is why the standard library is full of const char* signatures like strlen and strcmp.
  • String literals are const char *, and a const member function makes this a pointer-to-const, turning the whole object read-only inside it.
  • const_cast can strip const, but writing to a genuinely-const object afterward is undefined behaviour ☠️ ─ it’s a tool for legacy APIs, not for defeating our own const.

Once these click, const int * const ptr stops looking like line noise and starts reading like a plain English sentence ─ “a bolted-down remote pointing at a TV whose buttons are dead.” Two little locks, nothing more 👀✨

Congratulations 🥳🥳🥳
We now understand const pointers, top to bottom ─ the two locks, all four combinations, the right-to-left reading trick, const correctness, the this pointer, and the const_cast escape hatch. That’s a genuinely tricky one, checked off for good 💪

In the next post we’ll dig into NULL vs nullptr ─ why modern C++ introduced nullptr, and the sneaky type-safety trap where passing NULL can quietly call the wrong function overload 😉

Happy Coding 💻 🎵

Leave a Comment

Your email address will not be published. Required fields are marked *