The const Puzzle
Three words, one star, two positions. Six characters that trip up people who've been writing C for years. This post teaches you to read any const-pointer declaration in seconds, and to write APIs that document their own guarantees.
Why const exists at all
Here's a tiny problem. You're about to call a function that takes your buffer:
void process(char* buf);
Will it modify your buffer? Just looking at the signature, you have no idea. Maybe it fills in output. Maybe it only reads. Maybe it sometimes writes, depending on the weather. The name process is no help, and neither is the type: a non-const char* could be either.
Now compare:
void process(const char* buf);
Different story. The const is a promise from the function to the caller: "I will not modify what buf points at." The compiler enforces it. If process tries to write through buf, the code won't compile. The reader can pass their buffer in confidently, without wrapping it in a copy or auditing the implementation.
That's what const is for. It's a language-level way to write down guarantees about what won't be modified, so the compiler can keep those guarantees for you. It's not about making things "more safe" in some vague way; it's about making intent explicit and machine-checked.
const is a promise to do nothing in particular. A pointer with const is a promise, backed by the compiler, about what won't be modified.The three forms
When you start adding const to pointer declarations, there are three interesting forms. They look similar. They mean very different things. Let's meet them one at a time.
Form 1: pointer to const (read-only pointee)
const int* p; int const* p; // exact same thing, just different word order
Read it as: "p is a pointer to a const int." The thing it points at is read-only as seen through this pointer. You can reassign the pointer to point somewhere else, but you cannot write through it.
int a = 10, b = 20; const int* p = &a; // p points to a, which we treat as read-only *p = 99; // ERROR: cannot write through a pointer-to-const p = &b; // FINE: the pointer itself isn't const, we can reseat it a = 99; // FINE: a is not const. The const is about p's view of it.
That last line is worth pausing on. The const here restricts what you can do through the pointer, not what the underlying object is. If the object is itself modifiable, someone else (with a non-const pointer, or the variable name directly) can still change it. The const is about the view.
Form 2: const pointer (can't reseat)
int* const p;
Read it as: "p is a const pointer to an int." The pointer itself can't be reassigned once initialised, but you can freely modify what it points at.
int a = 10, b = 20; int* const p = &a; // must initialise at declaration, p is locked to &a forever *p = 99; // FINE: the pointee isn't const, we can write through p = &b; // ERROR: cannot reassign a const pointer
This form is less common in day-to-day code but shows up often in tight loops and embedded systems, where you want the optimiser to know "this pointer is never going to move."
Form 3: const pointer to const (locked both ways)
const int* const p;
Read it as: "p is a const pointer to a const int." You can neither reassign it nor write through it. It's a sealed view: once set, it points at one thing forever, and that thing is read-only as far as you're concerned.
int a = 10, b = 20; const int* const p = &a; *p = 99; // ERROR: pointee is const p = &b; // ERROR: pointer is const
Pick a preset or type your own. The decoder shows what's locked, what's not, and gives you a matrix of concrete operations labelled legal or illegal. Spend a minute flipping between the four main forms and watch which column goes green and which goes red.
The right-to-left rule
Once declarations get longer, parsing them by intuition stops working. You need a technique. The one every C programmer eventually learns is to read right-to-left, starting from the name and working outward.
Here's how it works. Find the variable's name. Then walk leftward, consuming one qualifier or token at a time, and translating each into English. When you hit a *, say "pointer to." When you hit const, say "const" (and attach it to whatever came just before). When you hit a type like int, that's your base.
Watch:
const int* const p; // ^-- start here: "p is" // ^------ "p is const" // ^-------- "p is const pointer to" // ^-------------- "p is const pointer to int" // ^------------------- "p is const pointer to const int"
The formula is "start at the name, walk left, narrate." The key insight is that every const modifies the thing immediately to its left, unless it's at the very beginning, in which case it modifies what's to its right. That's why const int* and int const* mean exactly the same thing. In the first, there's nothing to the left of const, so it binds to int (its right). In the second, int is to its left, so it binds there. Either way, you get "pointer to const int."
Pick a declaration. Each press of Next consumes one token from the right. The sentence at the bottom grows as the walk proceeds. Try the harder ones last; the technique is identical but there are more stops.
Const correctness: why you'd bother
Writing const everywhere it applies feels like extra typing for no payoff. It isn't. The discipline, called const correctness, pays for itself by turning guarantees into things the compiler can check. Three reasons to care:
1. It documents intent. A function signature like void print(const char* s) tells any future reader "this function won't scribble on your string." No comment needed, no trust required. The compiler enforces it. A signature like void print(char* s) tells you nothing; the reader has to audit the implementation or guess.
2. It catches bugs at compile time. If print accidentally writes through its parameter, the compile fails. Without const, the mistake would sit there, maybe write to a buffer the caller cares about, maybe never get caught in testing.
3. It spreads upward. Once you make print take const char*, its callers can hand in string literals, they can hand in pointers declared as const char*, and the compiler can allow the call. If print took non-const char*, a caller with a const char* would have to cast, and casts hide bugs. One function's const correctness reaches out and makes its callers' lives easier.
// Without const correctness: void print(char* s); const char* msg = "hello"; print(msg); // WARNING or ERROR: can't pass const char* to char* // With const correctness: void print(const char* s); const char* msg = "hello"; print(msg); // just works
Notice the direction. You can pass a non-const thing where a const thing is expected (adding a restriction is always fine), but you can't pass a const thing where a non-const thing is expected (relaxing a restriction needs permission). This asymmetry is what pushes const outward through a codebase: once someone introduces a const, upstream callers often need to become const-aware too.
Top-level and low-level const
Here is an idea that's obvious once you see it, and that unlocks every confusing thing const ever does.
A pointer is two things at once. On one hand, a pointer is itself an object: a variable that lives somewhere in memory, that can be assigned to, that has a value (an address). On the other hand, a pointer refers to another object somewhere else. Those are two different things, and both of them can independently be const or not.
- The pointer itself might be const (you can't reassign it), or not.
- What the pointer refers to might be const (you can't write through it), or not.
Those two independent choices give the three pointer-const forms we already covered. And the two positions have names worth learning.
int* const p; // top-level const: the pointer itself is const const int* p; // low-level const: what the pointer points at is const const int* const p; // both: outer const is top-level, inner const is low-level
Top-level const says "the pointer itself is const." Low-level const says "what the pointer points at is const." Two positions, two different meanings, totally independent of each other.
The idea is wider than just pointers
Most C++ references and most C textbooks first introduce top-level and low-level const with pointers, because pointers are where the distinction is most visible. But the idea is actually more general. Top-level const applies to any variable; low-level const applies to what a variable refers to when it's a pointer or reference.
const int x = 5; // top-level const. x itself is const. No low-level here. int* const p; // top-level const (on p). No low-level. const int* q; // low-level const (on what q points at). No top-level. const int* const r; // both. outer top-level, inner low-level.
The general rule: top-level const is about the variable itself; low-level const is about what the variable points at or refers to. A plain int can only have top-level const, because there's no "what it points at" to talk about. A pointer can have either, or both.
Why we bother to name this
The payoff for remembering these names is a single rule that explains most of the weird const-related behaviour you'll ever hit in C or C++:
That sounds abstract. Let's watch it play out.
Top-level const, dropped on copy:
const int a = 5; int b = a; // FINE. b is a fresh copy. top-level const gone. b = 99; // FINE. b is not const, we can modify it.
This is intuitive once you say it out loud. a being const means nobody can modify a. It says nothing about a different variable b that happens to hold a copy of a's value. The copy is its own independent thing, and its const-ness (or lack of it) is decided at the copy site, not inherited from the source.
Low-level const, preserved on copy:
const int x = 5; const int* p1 = &x; // p1 promises: won't write through const int* p2 = p1; // FINE. p2 carries the same promise. int* p3 = p1; // ERROR. Can't drop low-level const. // Would allow *p3 = 99 and break the promise.
This is the asymmetry. Copying the pointer is fine, as long as the new pointer also honours the promise that was attached to the old one. If you try to copy a "promise not to modify" pointer into a "free to modify" pointer, the compiler blocks you, because otherwise anyone holding a const int* could silently launder it into an int* and break the contract.
The puzzle this solves
Now we can explain something that surprises almost every C++ programmer the first time they see it. These two function declarations are considered the same function by the compiler:
void foo(const int x); void foo(int x);
Declaring both in the same scope is a duplicate definition error. Why? Because the const on x is top-level. When the caller passes an int, the int is copied into the parameter. Top-level const is dropped during that copy. So the two signatures look identical from the caller's perspective, and from the compiler's perspective they're the same function with the same parameter type.
Contrast with these two:
void foo(const int* x); void foo(int* x);
These are different functions. The const here is low-level, so it survives the copy into the parameter. The first function accepts pointers-to-const; the second accepts pointers-to-non-const. Different promises to the caller, different callable types, legitimately different functions in C++.
Two practical takeaways that are worth pinning to your wall:
- Top-level const on function parameters is mostly noise.
void foo(const int x)tells the caller nothing they can use. It only says "inside foo, the local variable x isn't reassigned." That's implementation detail. Some codebases do use it (in the definition, not the declaration) as internal discipline, but it never affects callers. - Low-level const on function parameters is the contract.
void foo(const int* x)is the compiler-checked promise that foo won't modify whatxpoints at. That's visible, meaningful, and useful. This is what "const correct" signatures are actually made of.
When you read a function signature and you want to know what the function promises you, the const that matters is the low-level one.
Casting const away
Sometimes you need to temporarily drop const. In C, a plain cast does it; in C++, the dedicated tool is const_cast:
Two situations where this is legitimate:
- Legacy C API interop. You have a
const char*and you're calling some old function whose signature takeschar*but which you know from documentation doesn't modify. Cast and call. - Internal mutable caches. A class marked
constexternally may still need to update a cache or mutex internally. In C++, themutablekeyword is the cleaner solution;const_castis the hack.
One situation where it's undefined behaviour:
const int x = 42; // x is REALLY const, not just viewed as const const int* p = &x; int* q = (int*)p; *q = 99; // UNDEFINED BEHAVIOUR. x was declared const.
The rule: you can cast const away and still only read without problems. But actually writing through the cast-away pointer is UB if the original object was declared const. That's because the compiler might have put x in read-only memory, or might have replaced every read of x with the constant 42 directly without ever touching memory. Writing through the cast would fight the compiler's optimisations and the OS's memory protection, and the result is anyone's guess.
The safe case is when the original object was not const, but you received it through a const pointer. Then writing through a cast is merely ugly, not UB, because the original bits are writable.
The one-definition-rule gotcha
Here's a subtle thing that bites people who share headers across multiple source files. In C, a global const variable has external linkage by default. In C++, it has internal linkage by default. That one-word difference causes real bugs.
Consider this header:
// config.h const int MAX_USERS = 100;
If you include this header in several .c files in C, you'll get a linker error: multiple definitions of MAX_USERS. C treats it as a real global, and the linker sees one per translation unit. The fix is to use extern in the header and define it in exactly one source file:
// config.h extern const int MAX_USERS; // config.c const int MAX_USERS = 100;
In C++, the same header just works across many .cpp files. Each translation unit gets its own private copy of MAX_USERS, and the compiler folds those copies into immediate values at compile time. No linker error. But there's a side effect: you can't take the address of MAX_USERS from one file and expect it to match the address from another, because they're genuinely different objects.
If you want a single shared const in C++, the idiom is inline constexpr (C++17) or extern const with a separate definition (works in all versions):
// config.hpp (C++17 or later) inline constexpr int MAX_USERS = 100;
Day-to-day, most consts live inside functions or classes where linkage doesn't matter. But the minute you put one in a header that multiple files include, you have to know this rule.
A brief C++ note: const member functions
C++ extends const to member functions. A const after a method's parameter list says "this method promises not to modify the object it's called on":
class User { int age; public: int get_age() const { return age; } // const method: doesn't modify the object void set_age(int a) { age = a; } // non-const: may modify };
The payoff is that const objects can only call const methods. If you write const User u;, you can read u.get_age() but not call u.set_age(30). The compiler uses the method's constness to figure out which calls are legal. This is what makes const correctness actually useful in object-oriented code: const is both a promise from the function and a gate on what the caller can do.
Watch how each toggle adds guarantees for the caller and removes capabilities from inside the function. This is the trade that const correctness is making, and the reason seasoned C/C++ codebases use it everywhere: for every lock you put on the implementation, the caller gains a compile-time guarantee they can trust.
Summing up
There are three meaningful positions for const in a pointer declaration. const int* locks the pointee (read-only view, pointer is free). int* const locks the pointer (pointer frozen, pointee is modifiable). const int* const locks both. Read right-to-left starting from the name, and every declaration decodes itself.
Const correctness is the discipline of adding const wherever it applies, so function signatures document their guarantees and the compiler enforces them. It spreads through a codebase because once one function is const-correct, its callers can pass const data, and their own signatures often gain const too.
Top-level const (on the variable itself) is discarded during copying. Low-level const (on what a pointer points at) is preserved. That's why const int and int are effectively the same as value parameters but const int* and int* are distinct types.
Casting const away is occasionally necessary for legacy interop, but writing through the cast when the original was declared const is undefined behaviour. In C, a global const in a header has external linkage and will cause multiple-definition errors if included from several files; in C++, it has internal linkage by default and you may want inline constexpr for a single shared value.
What's next
Post 8 covers two more pieces of the pointer language you'll meet in any serious C codebase: aliasing (the idea that two pointers can refer to the same memory, and why compilers worry about it) and void* (C's type-erased "pointer to anything"). Both of these are quietly powerful and quietly dangerous, in the way that const pretends not to be.
Test yourself
Seven questions on reading declarations, const correctness, top-level vs low-level, and the casting rule. Five correct means you're ready for Part 8.
int a = 10, b = 20; const int* p = &a; // Which of the following compile? // (A) *p = 99; // (B) p = &b; // (C) a = 99; // (D) int* q = p;
p is pointer-to-const, so you cannot write through it.B is valid. The pointer itself is not const, so you can reseat it to point at
b.C is valid.
a itself is not const. The const on p restricts what p can do, not what a can do. A different name for the same variable (or a non-const pointer) can still modify it.D is invalid. You'd be converting a const pointer-view to a non-const pointer-view, which would allow writes to something you promised not to write. The compiler blocks this.
// (a) const char* name; // (b) char* const pivot; // (c) const int* const cfg;
const char* name: "name is a pointer to a const char." Can be reseated. Cannot write through it. Ideal type for string parameters you don't modify.(b)
char* const pivot: "pivot is a const pointer to char." Cannot be reseated. Can write through it. Rare, but used when you want to pin a pointer in place while still mutating through it.(c)
const int* const cfg: "cfg is a const pointer to a const int." Cannot be reseated, cannot write through. Fully sealed. Often seen on configuration snapshots.
void log_message(char* msg) { printf("[LOG] %s\n", msg); } // caller: const char* err = "disk full"; log_message(err); // warning or error from the compiler
const char*.
The colleague's intent (never modify the string) is correct, but the signature doesn't document it. A caller with a const char* can't pass it to a function taking char*, because doing so would silently drop low-level const and open the door to writes the caller didn't authorise. The compiler rejects this for safety.The fix is one word:
void log_message(const char* msg) { printf("[LOG] %s\n", msg); }Now the signature matches the intent. Const
char* callers pass cleanly. Non-const char* callers also pass, because "I won't modify" is a weaker promise than "I might modify," and weakening restrictions upward is always fine.
const int a = 5; // (1) which kind of const? const int* p = &a; // (2) which kind of const? int* const q = (int*)&a; // (3) which kind of const? int b = a; // (4) compiles? int* r = p; // (5) compiles? int* s = q; // (6) compiles?
const applies to a itself.(2) low-level:
const applies to what p points at, not to p.(3) top-level:
const applies to q itself (the pointer). The pointee's int type has no const.(4) compiles: copying
a to b drops the top-level const. b is a fresh int.(5) fails:
p has low-level const. Copying to a non-const int* would strip a promise. The compiler rejects it.(6) compiles:
q's const was top-level (on the pointer itself). When we copy q to s, the pointer-const is gone, and the pointee type is still plain int. s can point-at and write-through freely.The key rule: copying drops top-level const; copying preserves low-level const.
Select the ones that are defined behaviour (not UB and not a compile/link error).
B is UB. The object was declared
const int, which might mean the compiler put it in read-only memory, inlined its value, or both. Writing through the cast fights these assumptions. Result: anything.C is defined. Ugly, but defined. The object's real type is
int; you received it through a const int* but the original storage is writable. The cast just relaxes a promise that existed at the type level.D is a link error in C. C gives global consts external linkage by default, so including the header in five files gives you five definitions of the same symbol. Fix:
extern const int MAX; in the header plus one definition in a .c file. In C++, this would work without error but you'd get five independent copies; inline constexpr is the modern fix.
class Account { double balance; public: double get_balance() const; void deposit(double x); }; Account acc; const Account frozen; acc.get_balance(); // (a) acc.deposit(100.0); // (b) frozen.get_balance(); // (c) frozen.deposit(100.0); // (d)
acc is not const, and get_balance is a const method. Non-const objects can call const methods. No issue.B compiles.
acc is not const, and deposit is a non-const method. Non-const objects can call non-const methods. Fine.C compiles.
frozen is const, but get_balance is marked const. Const objects can call const methods. The method's const is the permit slip.D fails.
frozen is const, but deposit is not a const method. A const object cannot call non-const methods, because doing so would let you modify something you promised to freeze. The compiler rejects the call outright.
log function accepts a message string and an optional destination file path. It should be as const-correct as possible. Write the signature.// Starting point: void log(char* message, char* dest_path);
const char* because log should not modify either string.
void log(const char* message, const char* dest_path);The const on each parameter says: this function does not modify the string you passed in. That's a contract, enforced by the compiler, visible in the signature. Callers can now pass string literals,
const char* variables, or non-const char* variables, all without friction.What you don't need: top-level const on the parameters (
char* const message). That would only say "inside the function, I won't reassign the local variable message to point elsewhere." Top-level const on value parameters is purely internal implementation detail and doesn't affect callers, so it belongs (if anywhere) in the definition, not the declaration. Const that documents guarantees to the caller is always low-level.
Comments