So You Think You Can Const?

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 to bob

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;
  • anotherFour is
    • a const pointer (*const)
    • to a const uint64_t (uint64_t const)

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)
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
    • uint64_t *bob;
  • Declare only const data, but allow pointer to change
    • uint64_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 const pointer, but allow data to change
    • uint64_t *const bob;
    • Valid pointer values are always a scalar memory addresses (uintptr_t), so this const has the same effect as using scalar const with regular integer values, meaning: it's okay if your implementation uses this const to qualify parameters but your function prototype doesn't need to include it since this const protects a pointer address, but not pointed-to data.
  • Declare const data and const pointer allowing nothing to change after initial declaration
    • uint64_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;
  • moreFour is
    • a pointer (*)
    • to a pointer (*)
    • to const uint64_t storage (uint64_t const)

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;
  • evenMoreFour is
    • a pointer (*)
    • to a const pointer (*const)
    • to const uint64_t storage (uint64_t const)

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;
  • ultimateFour is
    • a const pointer (*const)
    • to a const pointer (*const)
    • to const uint64_t storage (uint64_t const)

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 new const-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 const as many function parameters as you can
      • void trySomething(const storageStruct *const storage,
              const uint8_t *const ourData,
              const size_t len) {
        saveData(storage, ourData, len);
        }
    • Make all input arguments as const as possible when you don't need to modify data
  • 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.
    • 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 the const blocks, you could get undefined behavior.

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.

-Matt@mattsta☁mattsta

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;
}

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

  2. These are cases where it can be easier to write uint64_t const * instead of const 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 the const qualifier is after your type.

  3. This also proves, definitely, the correct pointer syntax is type *name and not type* name and especially not type * name because when you add const, the pointer attaches to the next const, not the previous qualifier. e.g.
    WRONG

    uint64_t const* const* evenMoreFour; /* pointers are each attached
                                            to the wrong const */

    The first const above belongs to uint64_t, not to the first pointer! The second const above belongs to the first pointer, not the second pointer!
    CORRECT

    uint64_t const *const *evenMoreFour; /* const properly attributed
                                            for reverse reading. */
  4. 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

  5. Reminder: const is only a compile time check; it does not change program behavior if you manage to violate const 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 place const data declarations in read only code segments, and hacking around those const blocks would cause undefined behavior.

  6. Also note the restrict keyword in the memcpy() prototype. restrict means "this pointer's data doesn't overlap with any other data in the current scope" which is the definition of how memcpy() expects to process its parameters.
    If you do need to copy memory from the same space onto itself, that's what memmove() is for, and the prototype for memmove() notably does not have any restrict qualifiers:

    void *memmove(void *dst, const void *src, size_t len);