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 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:

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.

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 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?

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)

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
  • 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 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

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)

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)

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)

Other Rules

  • It’s always safe to make any declaration const (if you don’t need to modify values)
  • 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:

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

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.

-Matt@mattsta☁mattsta

Try it Yourself


  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

    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

  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: