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 uint32_t hello = 3;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:
const uint32_t hello = 3;
uint32_t const hello = 3;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 uint16_t things[] = {5, 6, 7, 8, 9};const can still be after the type as well:
uint16_t const things[] = {5, 6, 7, 8, 9};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.
struct aStruct {
int32_t a;
uint64_t b;
};
const struct aStruct someStructA = {.a = 3, .b = 4};Or:
struct const aStruct someStructA = {.a = 3, .b = 4};If we try to modify any members of someStructA:
someStructA.a = 9;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:
someOtherStructB.a = 9;
someOtherStructB.b = 12;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.
uint64_t bob = 42;
uint64_t const *aFour = &bob;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?
*aFour = 44;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?
aFour = NULL;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.
uint64_t bob = 42;
uint64_t const *const anotherFour = &bob;
*anotherFour = 45;
anotherFour = NULL;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:
uint64_t const *const anotherFour = &bob;anotherFouris- a
constpointer (*const) - to a
constuint64_t(uint64_t const)
- a
Given our “normal” syntax of just
uint64_t const *anotherFour, read backwards, means:
aFouris- a regular mutable pointer (
*; meaning: the pointer itself can change) - to
constuint64_tdata (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
constand allow updates to both pointer and pointed-to datauint64_t *bob;
- Declare only
constdata, but allow pointer to changeuint64_t const *bob;- 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
constpointer, but allow data to changeuint64_t *const bob;- Valid pointer values are always a scalar memory addresses
(
uintptr_t), so thisconsthas the same effect as using scalarconstwith regular integer values, meaning: it’s okay if your implementation uses thisconstto qualify parameters but your function prototype doesn’t need to include it since thisconstprotects a pointer address, but not pointed-to data.
- Declare
constdata andconstpointer allowing nothing to change after initial declarationuint64_t const *const bob;
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.
uint64_t const **moreFour = &aFour;Which of these operations are allowed given the above declaration?
**moreFour = 46;
*moreFour = NULL;
moreFour = NULL;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:
uint64_t const **moreFour = &aFour;moreFouris- a pointer (
*) - to a pointer (
*) - to
constuint64_tstorage (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?
uint64_t const *const *evenMoreFour = &aFour;Given the two const above3,
what can we do now?
**evenMoreFour = 46;
*evenMoreFour = NULL;
evenMoreFour = NULL;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:
uint64_t const *const *evenMoreFour = &aFour;evenMoreFouris- a pointer (
*) - to a
constpointer (*const) - to
constuint64_tstorage (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?
uint64_t const *const *const ultimateFour = &aFour;Now what can we (not) do?
**ultimateFour = 48;
*ultimateFour = NULL;
ultimateFour = NULL;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:
uint64_t const *const *const ultimateFour = &aFour;ultimateFouris- a
constpointer (*const) - to a
constpointer (*const) - to
constuint64_tstorage (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-
constdata can be declared to a newconst-only variable- Creating immutable references to mutable storage is always allowed
uint32_t abc = 123; uint32_t *thatAbc = &abc; uint32_t const *const immutableAbc = thatAbc;
- Be safe and
constas many function parameters as you canvoid trySomething(const storageStruct *const storage, const uint8_t *const ourData, const size_t len) { saveData(storage, ourData, len); }
- Make all input arguments as
constas possible when you don’t need to modify data
- Any non-
constis only a compile time check.constvs. non-constdoesn’t alter program behavior.constexists 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.
constcan 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
constdeclarations in read-only storage, so if you attempt to hack around theconstblocks, 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:
const uint32_t hello = 3;
uint32_t *getAroundHello = &hello;
*getAroundHello = 92;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):
uint32_t *getAroundHello = (uint32_t *)&hello;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
constness.
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.
memcpy(&someStructA, &someOtherStructB, sizeof(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:
void *memcpy(void *restrict dst, const void *restrict src, size_t n);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?
memcpy(&someOtherStructB, &someStructA, sizeof(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
constscalars to functions using them as non-constparameters 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 theconstqualifier is after your type.↩︎This also proves, definitely, the correct pointer syntax is
type *nameand nottype* nameand especially nottype * namebecause when you addconst, the pointer attaches to the nextconst, not the previous qualifier. e.g.
WRONGuint64_t const* const* evenMoreFour; /* pointers are each attached to the wrong const */The first
constabove belongs touint64_t, not to the first pointer! The secondconstabove belongs to the first pointer, not the second pointer!
CORRECT↩︎uint64_t const *const *evenMoreFour; /* const properly attributed for reverse reading. */well, 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:
constis only a compile time check; it does not change program behavior if you manage to violateconstconstraints (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 placeconstdata declarations in read only code segments, and hacking around thoseconstblocks would cause undefined behavior.↩︎Also note the
restrictkeyword in thememcpy()prototype.restrictmeans “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 anyrestrictqualifiers:↩︎void *memmove(void *dst, const void *src, size_t len);