Chapter 12

Constants and Immutability

Images

Cippi admires her diamond.

I have a bit of an issue: On the one hand, pretty much everything in the five rules about constants and immutability has been covered in previous rules. On the other hand, writing your software using as much constant and immutable data as possible solves many challenges by design. Therefore, this section recapitulates the rules on constness and refers to previous rules when they provide additional value. In the end, const, constexpr, and immutability are such essential ideas that they should have an explicit place in this book about the C++ Core Guidelines.

Use const

Con.1

By default, make objects immutable

This rule is straightforward. You can make a value of a built-in data type or an instance of a user-defined data type const. The effect is the same. If you want to change it, you get a compiler error.

struct Immutable {
   int val{12};
};
int main() {
   const int val{12};
   val = 13;        // assignment of read-only variable 'val'
   
   const Immutable immu;
   immu.val = 13; 
   // assignment of member 'Immutable::val' 
   // in read-only object
}

Casting away const may cause undefined behavior if the underlying object is const: “ES.50: Don’t cast away const.”

Con.2

By default, make member functions const

Declaring member functions const has two obvious benefits. An immutable object can only invoke const member functions, and const member functions cannot modify the underlying object. Here is a short example that includes the error messages from GCC:

struct Immutable {
   int val{12};
   void canNotModify() const {
      val = 13; // assignment of member 'Immutable::val' 
               // in read-only object
   }
   void modifyVal() {
      val = 13;
   }
};
   
int main() {
   const Immutable immu;
   immu.modifyVal(); // passing 'const Immutable' as 'this' 
                    // argument discards qualifiers 
}

This was not the full truth. Sometimes you have to distinguish between the logical and the physical constness of an object. Sounds strange, right?

  • Physical constness: Your object is declared const and cannot, therefore, be changed. Its representation in memory is fixed.

  • Logical constness: Your object is declared const but could be changed. Its logical value is fixed, but its representation in memory may change at run time.

Physical constness is quite easy to comprehend, but logical constness is more subtle. Let me modify the previous example a bit. Assume I want to change the attribute val in a const member function.

// mutable.cpp
   
#include <iostream>
   
struct Immutable {
   mutable int val{12};              // (1)
   void canNotModify() const {
      val = 13;
   }
};
   
int main() {

   std::cout << '\n';
   
   const Immutable immu;
   std::cout << "val: " << immu.val << '\n';
   immu.canNotModify();           // (2)
   std::cout << "val: " << immu.val << '\n';
   
   std::cout << '\n';
   
}

The specifier mutable (1) made the magic possible. The const object can, therefore, invoke the const member function (2), which modifies val. See Figure 12.1.

Images

Figure 12.1 A mutable variable

Typically, a mutex used in a class member variable is mutable. Imagine your class has a read operation, which should be const. Because you use the data of the class concurrently, you have to protect the read member function with a mutex. So the class gets a mutex, and you lock the mutex in the read operation. Now you have an issue. Your read member function cannot be const because of the locking of the mutex. The solution is to declare the mutex as mutable.

Here is a sketch of the presented use case. Without mutable, this code would not work.

struct Immutable {
   mutable std::mutex m;
   int read() const {
      std::lock_guard<std::mutex> lck(m);
      // critical section

      ...
   }
};

Con.3

By default, pass pointers and references to consts

If you pass pointers or references to const to a function, the intention of the function is obvious. The pointed to or referenced object cannot be modified. This observation matches the previous rule covered in the section Parameter Passing: In and Out in Chapter 4.

void getCString(const char* cStr);
void getCppString(const std::string& cppStr);

Are both declarations equivalent? No! In the case of the function getCString, the pointer could be a null pointer. This means you have to check it before its usage: if (cStr) .... .

But there is even more. The pointer and the pointee can be const.

  • const char* cStr: cStr points to a char that is const; the pointee cannot be modified but the pointer can.

  • char* const cStr: cStr is a const pointer; the pointer cannot be modified but the pointee can.

  • const char* const cStr: cStr is a const pointer to a char that is const; neither the pointer nor the pointee can be modified.

Too complicated? Read the expressions from right to left or use a reference to const.

Con.4

Use const to define objects with values that do not change after construction

If you want to share a variable immutable between threads and this variable is declared as const, you are done. You can use const variables without synchronization, and you get the most performance out of your machine. The reason is quite simple. To get a data race, you need to have a mutable, shared state. I already wrote about data races in the section addressing concurrency and parallelism: “CP.2: Avoid data races.”

There is an additional problem to solve when using immutable and shared data in a concurrent environment. You have to initialize the shared variable in a thread-safe way. I have at least four ideas in mind.

  1. Initialize the shared variable before you start a thread.

  2. Use the function std::call_once in combination with the flag std::once_flag.

  3. Use a static variable with block scope.

  4. Use a constexpr variable.

In the rule “CP.3: Minimize explicit sharing of writable data,” I addressed these challenges.

Use constexpr

Con.5

Use constexpr for values that can be computed at compile-time

constexpr values give you better performance, are evaluated at compile time, and are never subject to data races. You must initialize a constexpr value constexprValue at compile time:

constexpr double constexprValue = constexprFunction(2);

A constexpr function constexprFunction can be executed at compile time. There is no state at compile time. A constexpr function, when executed at compile time, is pure. Pure functions have many advantages:

  1. The function call can be replaced by the result.

  2. The function can be performed on a different thread.

  3. A function call can be reordered.

  4. The function can easily be refactored or be tested in isolation.

Read more details about the benefits of constexpr functions in previous rules for functions: