C++ Const Conundrum

Paul J. Lucas - Aug 30 '23 - - Dev Community

Introduction

Early on, const was added to C++. (It was later back-ported to C.) There are three uses for const:

  1. const objects.
  2. Pointers or references to const.
  3. const member functions.

None are particularly complicated. However, constexpr was added in C++11 and both consteval and constinit were added in C++20. Knowing which of the four to use in a particular case can be quite the conundrum.

const

As a refresher, here are details for the use of const.

const Objects

For objects, there are two parts to “const-ness”:

  1. Whether it’s initialized at compile-time (vs. run-time) — constant initialization.

  2. Whether it’s immutable after being initialized — immutability.

An object declared const:

  • Means only #2 above: it’s immutable.

  • Must be explicitly initialized in its definition.

  • May be initialized with either a constant (compile-time) or dynamic (run-time) expression.

  • If initialized with a constant expression, it’s typically initialized at compile-time, but it’s not guaranteed to be by the implementation.

  • Is static (has internal linkage) by default.

  • May be explicitly declared extern (have external linkage).

C also has const objects, except in C, const is extern by default. If compatibility with C is required or you just want to be clear and not have to remember what the default linkage is in which language, always explicitly specify either static or extern.

For example:

int const ANSWER = 42;      // Initialized at compile-time.
int const SEED   = rand();  // Initialized at run-time.

// file.h
extern int const MAX;       // Declaration.

// file.cpp
int const MAX = 10;         // Definition.
Enter fullscreen mode Exit fullscreen mode

Note that the compiler doesn’t care where you put const:

const int WEST = 180;
int const EAST = 0;  // Same as: const int.
Enter fullscreen mode Exit fullscreen mode

The latter style is known as east const and it’s the style I prefer because then const is always making constant what’s to its immediate left (hence, the const is to the right or “east”). (This becomes relevant when pointers are involved.)

Attempting to modify a constant object results in undefined behavior:

int *p = const_cast<int*>(&MAX);
*p = 43;                    // Undefined behavior.
Enter fullscreen mode Exit fullscreen mode

Pointers or References to const

A pointer or reference to const can refer to either a const or non-const object. The const means that you can’t modify the referred-to object via that pointer or reference. Whether the referred-to object is actually const is irrelevant:

int i = 42;
int const *p = &i;  // pointer to const pointing at non-const
*p = 43;            // error: can't modify via pointer to const
Enter fullscreen mode Exit fullscreen mode

Note that the pointer itself can also be const:

int *const cp = &i;         // constant pointer to non-const
int const *const cpc = &i;  // constant pointer to const
Enter fullscreen mode Exit fullscreen mode

For a constant pointer, the const must appear to the right (“east”) of the *.

You’ll often read or hear pointers or references to const referred to as “const pointers” or “const references” (respectively). The problem is that, for pointers, “const pointer” technically means the pointer is const, not the pointed-to object. (Note that “const reference” doesn’t have this problem because all references are const, hence “const reference” always means “reference to const.”) Therefore to be precise for pointers, I’ll use “pointer to const” unless I really mean “constant pointer.” (I’ll also use “reference to const” for symmetry.)

const Member Functions

Marking a member function as const means it can be called on const objects (or via pointers or references to const objects). Given:

class C {
    // ...
    void f();
    void fc() const;
};
Enter fullscreen mode Exit fullscreen mode

Then:

C c;
C const cc;

c.f();    // OK.
c.fc();   // OK.
cc.f();   // Error: non-const f() called on const object.
cc.fc();  // OK.

C *p = &c;
C const *pc = &c;  // Not a typo.

p->f();   // OK.
p->fc();  // OK.
pc->f();  // Error: non-const f() called via pointer to const.
pc->fc(); // OK.
Enter fullscreen mode Exit fullscreen mode

constexpr

C++11 added constexpr that’s a “const-er” const for both objects and functions.

constexpr Objects

An object declared constexpr, like const:

  • Must be explicitly initialized in its definition.

However, unlike const:

  • Is always initialized at compile-time.

  • Must be initialized with a constant expression.

  • Implies const (and const implies static).

  • May not be explicitly declared extern (external linkage).

  • Is itself a constant expression.

A constexpr object can be used in places where constant expressions are required such as array dimensions and case labels.

constexpr int N = 42;      // OK.
constexpr int R = rand();  // Error.
Enter fullscreen mode Exit fullscreen mode

constexpr Functions

A function declared constexpr:

  • Implies inline.

However, unlike inline:

  • Will be evaluated at compile-time (but only if all arguments are constant expressions).

  • Can be used to initialize objects declared constexpr (when evaluated at compile-time).

  • May not call non-constexpr functions.

For example:

constexpr auto max( auto i, auto j ) {
    return i > j ? i : j;
}

constexpr int N = max( 1, 2 );  // Compile-time.
Enter fullscreen mode Exit fullscreen mode

Note that constexpr is a specifier (like extern, static, thread_local, etc.) whereas const is a qualifier:

const int f();      // const applies to int.
constexpr int g();  // constexpr applies to g(), not int.
static int h();     // Just like static applies to h().
Enter fullscreen mode Exit fullscreen mode

While f() returning int qualified with const is legal, it’s pointless. It’s done here only to illustrate another difference between const and constexpr.

Perhaps curiously, constexpr does not require functions to be evaluated at compile-time. If you supply at least one non-constant argument, the function will be evaluated at run-time:

int a = 1, b = 2;

constexpr int M1 = max( 1, 2 );  // OK: compile-time.
const     int M2 = max( a, b );  // OK: run-time.
constexpr int M3 = max( a, b );  // Error: not a constant expression.
Enter fullscreen mode Exit fullscreen mode

Hence, a constexpr function will be evaluated at compile-time only if it can be.

constexpr Function Rationale

At this point, you might ask:

Why are constexpr functions necessary? Why can’t an ordinary inline function given only constant expression arguments result in a constant expression? Why can’t the compiler just figure it out?

It could, but the reason it doesn’t is that a constexpr function forbids calling non-constexpr functions:

inline int r1( int n ) {
    return n * rand();  // OK.
}

constexpr int r2( int n ) {
    return n * rand();  // Error: call non-constexpr function.
}
Enter fullscreen mode Exit fullscreen mode

This is good because it prevents you from accidentally introducing a call to a non-constexpr function resulting in the function as a whole silently changing from a constant expression to a non-constant expression. Specifying constexpr is a statement of intent to the compiler that you want its help to keep a function a constant expression.

constexpr Constructors & Member Functions

In addition to functions being constexpr, constructors can also be constexpr that allows entire class objects to be initialized at compile-time:

class C {
public:
    constexpr C( int n ) : _n{ n } { }
private:
    int _n;
};

constexpr C c{ 42 };  // Constructed at compile-time.
Enter fullscreen mode Exit fullscreen mode

Member functions can also be constexpr just like non-member functions; constexpr member functions can also be const:

class C {
public:
    int f();
    int f() const;      // Can overload by const.

    constexpr int g();
    constexpr int g() const;

    int h();
    constexpr int h();  // Error: can't overload by constexpr.
};
Enter fullscreen mode Exit fullscreen mode

consteval

C++20 added consteval that’s an even “const-er” constexpr, but only for functions.

A function declared consteval, like constexpr:

  • Can be used to initialize objects declared constexpr.

However, unlike constexpr:

  • All arguments must be constant expressions.

  • Will always be evaluated at compile-time.

  • Is known as an immediate function.

  • No code is generated for it; therefore you can’t have a pointer or reference to it.

Some examples of the differences between constexpr and consteval:

constexpr auto max_expr( auto i, auto j ) {
    return i > j ? i : j;
}

consteval auto max_eval( auto i, auto j ) {
    return i > j ? i : j;
}

int a = 1, b = 2;

constexpr int M1 = max_expr( 1, 2 );  // OK: compile-time.
const     int M2 = max_expr( a, b );  // OK: run-time.

constexpr int M3 = max_eval( 1, 2 );  // OK: compile-time.
const     int M4 = max_eval( 1, 2 );  // OK: compile-time.
const     int M5 = max_eval( a, b );  // Error: can't be compile-time.
Enter fullscreen mode Exit fullscreen mode

constinit

C++20 also added constinit that requires constant initialization, but only for global, static, or thread_local objects (no non-static local objects nor non-static data members), nor functions.

An object declared constinit, like const:

  • Must be explicitly initialized in its definition.

  • May be explicitly declared extern.

However, unlike const:

  • Is always initialized at compile-time.

  • Must be initialized with a constant expression.

  • Is not immutable by default.

  • Is not a constant expression.

A constinit declaration need not be a definition:

// file.h
extern constinit int MAX;

// file.cpp
constinit int MAX = 42;
Enter fullscreen mode Exit fullscreen mode

constinit was added to help avoid the static initialization order fiasco. For example, given:

// file1.cpp
int square( int n ) {
    return n * n;
}

int C1 = square( 5 );

// file2.cpp
extern int C1;
int C2 = C1;        // C2 can be either 0 or 25.
Enter fullscreen mode Exit fullscreen mode

If the variables in file1.cpp are dynamically initialized before those in file2.cpp, then C2 will be 25; however, if the variables in file2.cpp are dynamically initialized before those in file1.cpp, then C2 will be 0.

There’s a 50:50 chance of which value C2 will get. This is determined at compile-time, so multiple runs of the program will always produce the same value consistently. However, one day, you might change the program in a seemingly inconsequential way, change the object file link order, change the compiler options, or do something else that will cause the variables to be initialized in the wrong order and the program will break. Initialization dependency order bugs are very hard to find.

There is no way to force a specific order of dynamic initialization. However, there is a work-around:

// file1.cpp
int square( int n ) {
    return n * n;
}

int& get_C1() {
    static int C1 = square( 5 );
    return C1;
}

// file2.cpp
int& get_C1();
int C2 = get_C1();  // C2 is always 25.
Enter fullscreen mode Exit fullscreen mode

This makes C1 local to a function. C++ guarantees that static local variables are initialized before their first use. So even if the variables in file2.cpp are initialized first, it doesn’t matter because C1 will be initialized regardless prior to get_C1() returning.

With constinit, you don’t need the work-around because it guarantees C1 will be initialized at compile-time:

// file1.cpp
consteval int square( int n ) {
    return n * n;
}

constinit int C1 = square( 5 );

// file2.cpp
extern int C1;
int C2 = C1;        // C2 is always 25.
Enter fullscreen mode Exit fullscreen mode

Miscellaneous

The following are miscellaneous declarations. The first is of the form:

constexpr const int CECI = 42;  // const is redundant.
Enter fullscreen mode Exit fullscreen mode

Since constexpr implies const, adding const to a constexpr is pointless.

The second is of the form:

constinit const int CICI = 42;  // Should likely be constexpr.
Enter fullscreen mode Exit fullscreen mode

A statically initialized, immutable object should be constexpr instead, if possible. However, one use-case for a constinit const would be if you wanted to have an immutable object that’s statically initialized by a non-trivial constexpr or consteval function that you do not want to put into a header:

// file.h
extern constinit const int CICI;

// file.cpp
constexpr int calc_CICI() {
    // ...
}

constinit const int CICI = calc_CICI();
Enter fullscreen mode Exit fullscreen mode

“Const Correctness”

You’ve likely heard the term const correctness. Historically, this involved only const. With the additions of constexpr, consteval, and constinit, does the meaning of const correctness change?

Yes. Objects should be in order of preference from most-to-least “const”:

  1. constexpr.
  2. constinit (but only if mutability is needed).
  3. const.

For inline functions, either non-member or static member:

  1. consteval.
  2. constexpr.

For inline, non-static member functions:

  1. const consteval.
  2. const constexpr.
  3. consteval.
  4. constexpr.

Summary

To summarize, consider this diagram:

Const Venn Diagram

First, there are two categories of “const-ness”: for objects and for functions. For objects:

  • const is immutable and may be either statically or dynamically initialized.

  • constexpr is also immutable and must be statically initialized.

  • constinit is mutable and must be statically initialized.

For functions:

  • const only applies to member functions and their objects are immutable.

  • inline functions are always evaluated at run-time.

  • constexpr functions are evaluated at compile-time only if all their arguments are constant expressions; otherwise they are evaluated at run-time.

  • consteval functions are always evaluated at compile-time.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .