So You Think You Can const
Think you you know all the const
rules for C? Think again.
Previously: How to C
Basic const
Scalar
You’re familiar with simple const
rule in C.
const
on hello
means at compile time your compiler verifies hello
never changes.
If you try to modify or re-assign hello
, your compiler stops you:
clang-700.1.81: error: read-only variable is not assignable hello++; ~~~~~^ error: read-only variable is not assignable hello = 92; ~~~~~ ^ gcc-5.3.0: error: increment of read-only variable 'hello' hello++; ^ error: assignment of read-only variable 'hello' hello = 92; ^
Additionally, C doesn’t really care where const
shows up as long as it’s before your identifier, so const uint32_t
and uint32_t const
are identical:
Prototypal Scalar Const
Compare the following function prototype versus implementation:
void printTwo(uint32_t a, uint64_t b);
void printTwo(const uint32_t a, const uint64_t b) {
printf("%" PRIu32 " %" PRIu64 "\n", a, b);
}
Will your compiler complain if printTwo()
implementation has scalar const
parameters but its prototype doesn’t?
Nope.
It’s perfectly okay to have mismatching prototype const
qualifiers for scalar implementation arguments.
Why is this okay? Pretty simple: there’s no way your function can modify a
or b
outside of the function scope itself, so const
has no impact on any callers of your function. Your compiler is smart enough to know it will be copying the scalars a
and b
, so const
-vs-not-const
has no impact on the physical or mental models of your program here.
Your compiler won’t care if you have mismatching const
for any non-pointer or non-array parameters since those will be copied directly by value and the original values at the caller will always remain unmodified1.
Though, your compiler will complain if you have mismatching const
with pointer or array parameter data because then your function has the ability to manipulate pointed-to data passed in which must be agreed upon by the caller as well as the callee.
Array
You can const
an entire array declaration.
const
can still be after the type as well:
If you try to modify things[]
, your compiler stops you:
clang-700.1.81: error: read-only variable is not assignable things[3] = 12; ~~~~~~~~~ ^ gcc-5.3.0: error: assignment of read-only location 'things[3]' things[3] = 12; ^
Struct
Regular Struct
You can const
an entire struct.
Or:
If we try to modify any members of someStructA
:
We get an error because someStructA
is declared as const
. We can’t modify members after declaration.
clang-700.1.81: error: read-only variable is not assignable someStructA.a = 9; ~~~~~~~~~~~~~ ^ gcc-5.3.0: error: assignment of member 'a' in read-only object someStructA.a = 9; ^
Internal const
Struct
You can const
individual members of a struct.
struct anotherStruct {
int32_t a;
const uint64_t b;
};
struct anotherStruct someOtherStructB = {.a = 3, .b = 4};
If we try to modify any members of someOtherStructB
:
We only get an error on modifying b
because b
is declared const
inside the struct itself:
clang-700.1.81: error: read-only variable is not assignable someOtherStructB.b = 12; ~~~~~~~~~~~~~~~~~~ ^ gcc-5.3.0: error: assignment of read-only member 'b' someOtherStructB.b = 12; ^
Declaring an entire instance of a struct
with a const
qualifier is the same as creating a special-purpose copy of the struct with all members specified as const
. If you don’t need a 100% const
struct, you can const
only specific members in individual struct
declarations where necessary.
Pointer
const
pointers is where the fun starts.
One const
Let’s use a pointer to an integer as an example.
Since this is a pointer, we have two storage complications happening here:
- the data storage of
bob
- the pointer storage of
aFour
, a pointer tobob
So, what can we do with aFour
? Let’s try a few things.
Do you think dereferencing the const
pointer allows for modification?
clang-700.1.81: error: read-only variable is not assignable *aFour = 44; ~~~~~~ ^ gcc-5.3.0: error: assignment of read-only location '*aFour' *aFour = 44; ^
How about just updating the const
pointer without modifying the pointed-to value?
That actually works and is perfectly valid. We declared uint64_t const *
meaning just “pointer to const data,” but the pointer itself is not const (also note: const uint64_t *
has the same meaning).
How can we make both the data and the pointer const
at the same time? Introducing: double const.
Two const
Let’s add another const and see how things play out.
What’s the result?
clang-700.1.81: error: read-only variable is not assignable *anotherFour = 45; ~~~~~~~~~~~~ ^ error: read-only variable is not assignable anotherFour = NULL; ~~~~~~~~~~~ ^ gcc-5.3.0: error: assignment of read-only location '*anotherFour' *anotherFour = 45; ^ error: assignment of read-only variable 'anotherFour' anotherFour = NULL; ^
Ah ha! We successfully made the data and the pointer itself const
.
What does const *const
mean?
Meanings seem less obvious here.
Meanings are so wonky it’s actually recommended to read declarations backwards (or worse, spiral).
In this case, when read backwards2, the declaration means:
anotherFour
is- a
const
pointer (*const
) - to a
const
uint64_t
(uint64_t const
)
- a
Given our “normal” syntax of just uint64_t const *anotherFour
, read backwards, means:
aFour
is- a regular mutable pointer (
*
; meaning: the pointer itself can change) - to
const
uint64_t
data (uint64_t const
; meaning: the data pointed to cannot change)
- a regular mutable pointer (
What did we just see?
An important distinction here: people typically call const uint64_t *bob
a “const pointer,” but that is not what happens here. It’s actually “mutable pointer to const
data.”
Intermission — Explaining C const
declarations
But wait, there’s more!
We just saw how the introduction of a pointer gave us four const
options for our declaration. We can:
- Declare nothing
const
and allow updates to both pointer and pointed-to data - Declare only
const
data, but allow pointer to change- This is a common pattern for iterating over sequential data: advance to the next element by incrementing a pointer, but don’t allow the pointer to modify data.
- Declare only
const
pointer, but allow data to change- Valid pointer values are always a scalar memory addresses (
uintptr_t
), so thisconst
has the same effect as using scalarconst
with regular integer values, meaning: it’s okay if your implementation uses thisconst
to qualify parameters but your function prototype doesn’t need to include it since thisconst
protects a pointer address, but not pointed-to data.
- Declare
const
data andconst
pointer allowing nothing to change after initial declaration
That’s with one pointer and two const, but what if we add another pointer?
Three const
One
How many ways can we annotate const
with a double pointer?
Let’s do a quick sanity check.
Which of these operations are allowed given the above declaration?
clang-700.1.81: error: read-only variable is not assignable **moreFour = 46; ~~~~~~~~~~ ^ gcc-5.3.0: error: assignment of read-only location '**moreFour' **moreFour = 46; ^
Only the first assignment failed because our declaration, when read backwards:
moreFour
is- a pointer (
*
) - to a pointer (
*
) - to
const
uint64_t
storage (uint64_t const
)
- a pointer (
As we saw, the only operation we couldn’t complete is modifying the actual storage value. We successfully modified both the pointer and the pointed to pointer.
Two
What if we want to add another const
one level deeper?
Given the two const
above3, what can we do now?
clang-700.1.81: error: read-only variable is not assignable **evenMoreFour = 46; ~~~~~~~~~~~~~~ ^ error: read-only variable is not assignable *evenMoreFour = NULL; ~~~~~~~~~~~~~ ^ gcc-5.3.0: error: assignment of read-only location '**evenMoreFour' **evenMoreFour = 46; ^ error: assignment of read-only location '*evenMoreFour' *evenMoreFour = NULL;
Now we’re locked out from modifying twice because our declaration, when read backwards:
evenMoreFour
is- a pointer (
*
) - to a
const
pointer (*const
) - to
const
uint64_t
storage (uint64_t const
)
- a pointer (
Three
We can do one better than two. Introducing: three const
.
What if we want to lock down all changes to everything in a double pointer declaration?
Now what can we (not) do?
clang-700.1.81: error: read-only variable is not assignable **ultimateFour = 46; ~~~~~~~~~~~~~~ ^ error: read-only variable is not assignable *ultimateFour = NULL; ~~~~~~~~~~~~~ ^ error: read-only variable is not assignable ultimateFour = NULL; ~~~~~~~~~~~~ ^ gcc-5.3.0: error: assignment of read-only location '**ultimateFour' **ultimateFour = 46; ^ error: assignment of read-only location '*ultimateFour' *ultimateFour = NULL; ^ error: assignment of read-only variable 'ultimateFour' ultimateFour = NULL; ^
Nothing worked! Success!
Here we go, one more time:
ultimateFour
is- a
const
pointer (*const
) - to a
const
pointer (*const
) - to
const
uint64_t
storage (uint64_t const
)
- a
Other Rules
- It’s always safe to make any declaration
const
(if you don’t need to modify values)- Any non-
const
data can be declared to a newconst
-only variable- Creating immutable references to mutable storage is always allowed
- Be safe and
const
as many function parameters as you can - Make all input arguments as
const
as possible when you don’t need to modify data
- Any non-
const
is only a compile time check.const
vs. non-const
doesn’t alter program behavior.const
exists to help humans handle complexity a little easier.- helps self-document expected behavior of variables and parameters.
- serves as easy maintenance guard if you forget what should vs. shouldn’t be modified in the future.
- helps self-document expected behavior of variables and parameters.
const
can always be hacked around by explicit casting or memory copying (or worse: memory corruption changing values out from under you).- Your compiler, at its discretion, may also choose to place any
const
declarations in read-only storage, so if you attempt to hack around theconst
blocks, you could get undefined behavior.
- Your compiler, at its discretion, may also choose to place any
Hacks
Casting Hackery
What if you’re clever and make a non-const
pointer to const
storage like:
Your compiler will complain you dropped const
, but with just a warning4 you can disable5:
clang-700.1.81: warning: initializing 'uint32_t *' (aka 'unsigned int *') with an expression of type 'const uint32_t *' (aka 'const unsigned int *') discards qualifiers [-Wincompatible-pointer-types-discards-qualifiers] uint32_t *getAroundHello = &hello; ^ ~~~~~~ gcc-5.3.0: warning: initialization discards 'const' qualifier from pointer target type [-Wdiscarded-qualifiers] uint32_t *getAroundHello = &hello; ^
Since this is C, you can cast away the const
qualifier and dismiss the warning (also violating the initial const
constraint):
Now you have no warnings on compile because you’ve explicitly told your compiler to ignore the actual type of &hello
in favor of your re-interpretation as uint32_t *
.
Memory Hackery
What if a struct
has const
members, but you modify struct
storage after declaration?
Let’s define two structs only differing in member const
ness.
struct exampleA {
int64_t a;
uint64_t b;
};
struct exampleB {
int64_t a;
const uint64_t b;
};
const struct exampleA someStructA = {.a = 3, .b = 4};
struct exampleB someOtherStructB = {.a = 3, .b = 4};
Try to copy someOtherStructB
onto const
someStructA
.
Does that work?
clang-700.1.81: warning: passing 'const struct aStruct *' to parameter of type 'void *' discards qualifiers [-Wincompatible-pointer-types-discards-qualifiers] memcpy(&someStructA, &someOtherStructB, sizeof(someStructA)); ^~~~~~~~~~~~ gcc-5.3.0: In file included from /usr/include/string.h:186:0: warning: passing argument 1 of '__builtin___memcpy_chk' discards 'const' qualifier from pointer target type [-Wdiscarded-qualifiers] memcpy(&someStructA, &someOtherStructB, sizeof(someStructA)); ^ note: expected 'void *' but argument is of type 'const struct aStruct *'
Nope, that doesn’t work because the prototype6 for memcpy
is:
memcpy
doesn’t allow const
pointers to be passed as dst
argument because dst
is mutated by the copy (and someStructA
is const
).
Though, const
parameter validation is only enforced by the function prototype. Will the compiler complain if we use a non-entirely-const
struct with some internal const
fields as dst
?
What happens if we attempt to copy const
someStructA
into not-const
-but-has-one-const
-member someOtherStructB
?
The default compiler prototype check now passes and we get no warnings with this memcpy
, even though we are overwriting a const
member of a not-entirely-const
struct.
Conclusion
Stop mutating values unnecessarily. Be strict with making the implementation of your programs match the mental model of how you expect your programs to actually work.
The less data you change, the less you’ll break the world.
Try it Yourself
#include <stddef.h> /* gives us NULL */
#include <stdint.h> /* gives us exact integer width types */
int main(void) {
uint64_t bob = 42;
const uint64_t *aFour = &bob;
/* uint64_t const *aFour = &bob; */
*aFour = 44; /* NO */
aFour = NULL;
const uint64_t *const anotherFour = &bob;
/* uint64_t const *const anotherFour = &bob; */
*anotherFour = 45; /* NO */
anotherFour = NULL; /* NO */
const uint64_t **moreFour = &aFour;
/* uint64_t const **moreFour = &aFour; */
**moreFour = 46; /* NO */
*moreFour = NULL;
moreFour = NULL;
const uint64_t *const *evenMoreFour = &aFour;
/* uint64_t const *const *evenMoreFour = &aFour; */
**evenMoreFour = 47; /* NO */
*evenMoreFour = NULL; /* NO */
evenMoreFour = NULL;
const uint64_t *const *const ultimateFour = &aFour;
/* uint64_t const *const *const ultimateFour = &aFour; */
**ultimateFour = 48; /* NO */
*ultimateFour = NULL; /* NO */
ultimateFour = NULL; /* NO */
return 0;
}
also meaning: it’s perfectly safe to pass
const
scalars to functions using them as non-const
parameters because the functions have no way to modify your original scalar values↩These are cases where it can be easier to write
uint64_t const *
instead ofconst uint64_t *
because both declarations have the exact same behavior, but, when reading backwards, you don’t have to pretend your declaration is circular when theconst
qualifier is after your type.↩This also proves, definitely, the correct pointer syntax is
type *name
and nottype* name
and especially nottype * name
because when you addconst
, the pointer attaches to the nextconst
, not the previous qualifier. e.g.
WRONGThe first
↩const
above belongs touint64_t
, not to the first pointer! The secondconst
above belongs to the first pointer, not the second pointer!
CORRECTwell, one non-standardized warning flag per compiler model, so your build process would need many redundant flags for cross-compiler compatibility to disable these warnings↩
Reminder:
const
is only a compile time check; it does not change program behavior if you manage to violateconst
constraints (any more than altering any other value changes your program behavior), but it will probably alter your expectations of what is actually going on. Also: your compiler may placeconst
data declarations in read only code segments, and hacking around thoseconst
blocks would cause undefined behavior.↩Also note the
↩restrict
keyword in thememcpy()
prototype.restrict
means “this pointer’s data doesn’t overlap with any other data in the current scope” which is the definition of howmemcpy()
expects to process its parameters.
If you do need to copy memory from the same space onto itself, that’s whatmemmove()
is for, and the prototype formemmove()
notably does not have anyrestrict
qualifiers: