Constants and const-Correctness

Learn the intricacies of using const and how to apply it in different contexts
This lesson is part of the course:

Intro to C++ Programming

Become a software engineer with C++. Starting from the basics, we guide you step by step along the way

Free, Unlimited Access
3D art showing a fantasy maid character
Ryan McCombe
Ryan McCombe
Edited

In our original lesson on references, we introduced the concept of pass-by value. This means that, when we call a function, the parameters are generated by creating copies of the arguments. Below, MyFunction() receives a copy of Player.

class Character {/*...*/}; // Passing by value void MyFunction(Character Input) {} int main() { Character Player; MyFunction(Player); }

When our parameters are non-trivial, this can be problematic, as copying a complex object has a performance overhead. Additionally, it’s quite unusual for our functions to actually require a copy. Therefore, we prefer to pass objects by reference (or pointer) where possible.

Below, MyFunction() and main() share the same copy of Player. MyFunction() can access Player through the Input reference:

class Character {/*...*/}; // Passing by reference void MyFunction(Character& Input) {} int main() { Character Player; MyFunction(Player); }

const-Correctness

However, passing by reference opens up another problem. When we pass an object to a function by value, we know our object is not going to be modified by that function call. Any action the function performs on the object will be performed on a copy - our original object will be safe.

When we pass by reference, that is no longer the case. The function could modify our object, so we need to consider what state our object could be in after our function call:

class Character {/*...*/}; void MyFunction(Character& Input) {} int main() { Character Player; MyFunction(Player); // What is Player's health now? }

Having to read through a function’s body (or documentation) to understand what effect it can have on our variable is quite annoying. Fortunately, in almost all cases, a function will not change the object at all. It is simply receiving it by reference to avoid the performance impact of creating a copy.

In these scenarios, we can mark the parameter as const. This communicates to the caller that their variable isn’t going to be modified, and asks the compiler to take steps to ensure that it is not modified.

class Character {/*...*/}; void MyFunction(const Character& Input) {} int main() { Character Player; MyFunction(Player); // What is Player's health now? }

When our code correctly marks variables that won’t be changed as const, it is considered ***const-correct***.

Placing const after the type

It is also valid to place the const qualifier after the type it applies to, although this pattern is less commonly used:

void MyFunction(Character& const Input) {}

Constants and Immutability

The const keyword is an abbreviation of constant - a constant simply refers to something that doesn’t change. Phrases like "immutability" are sometimes used to refer to the same concept.

Changing an object is sometimes referred to as mutating it. An object that can change is mutable, whilst an object that cannot change, ie a constant, is immutable.

const Member Functions

In this lesson, we will explain how they can be used in many other situations. The const keyword can be used in a lot of different ways in C++, and they interact to help us fully implement const-correctness.

In our previous example, we marked our Character as const, meaning the compiler will stop us from doing anything that could change it. This includes calling any functions on the object. The following program will generate a compilation error when we try to call GetHealth() on our const reference:

#include <iostream>

class Character {
 public:
  int GetHealth() { return mHealth; }
  void SetHealth(int Health) {
    mHealth = Health;
  }

 private:
  int mHealth{100};
};

void MyFunction(const Character& Input) {
  std::cout << "Health: " << Input.GetHealth();
}

int main() {
  Character Player;
  MyFunction(Player);
}

In this class, GetHealth() isn’t modifying the object - it’s simply reading a variable. So, we should mark that function as const too. This asks the compiler to ensure nothing in the GetHealth() function body modifies our object:

#include <iostream>

class Character {
 public:
  int GetHealth() const { return mHealth; }
  void SetHealth(int Health) {
    mHealth = Health;
  }

 private:
  int mHealth{100};
};

void MyFunction(const Character& Input) {
  std::cout << "Health: " << Input.GetHealth();
}

int main() {
  Character Player;
  MyFunction(Player);
}

A side effect of this is that it also fixes the compilation error we had. Since we declared the GetHealth() function does not modify the object, we can now call it with a const reference. Our previous program now compiles successfully, and outputs:

Health: 100

const Variables

We’re not restricted to just using const with references. We can declare any variable as const:

int main() {
  const int Health{100};
  Health++;
}
error: expression must be modifiable

Member variables can also be const:

class Character {
 public:
  const int Level{1};
};

int main() {
  Character Player;
  Player.Level++;
}
error: 'Player': you cannot assign to a variable that is const

Additionally, identifiers that point to complex objects can be const, even if they’re not references:

class Character {
 public:
  int Level{1};
};

int main() {
  Character PlayerOne;
  PlayerOne.Level++; // this is fine

  const Character PlayerTwo;
  PlayerTwo.Level++; // this is not 
}
error: 'PlayerTwo': you cannot assign to a variable that is const

By marking a variable as const, the compiler will also prevent us from creating a reference to that variable, unless the reference is also const:

class Character {
 public:
  int Level{1};
};

// Passing by non-const reference
void MyFunction(Character& Input) {}

int main() {
  Character PlayerOne;
  MyFunction(PlayerOne); // this is fine

  const Character PlayerTwo;
  MyFunction(PlayerTwo); // this is not
}
error: cannot convert argument 1 from 'const Character' to 'Character &'

Finally, we can copy a const variable to a non-const variable. This is most relevant when passing a const variable by value to a non-const function parameter. This is allowed because, within the function, any modifications would be done to a copy.

As we’ve seen before, the original const variable is not being modified:

#include <iostream>

void MyFunction(int Number) {
  // incrementing a copy
  Number++;
}

int main() {
  const int Number{5};

  // Passing a const variable by value to a
  // non-const function parameter is fine
  MyFunction(Number);

  std::cout << "Number is still " << Number;
}
Number is still 5

const Pointers

An interesting property comes up when considering const from the perspective of pointers. This is because the pointer, and the thing it is pointing at, are two separate objects.

Therefore, our use of const in this context can take four different forms:

Nothing is const

When neither the pointer nor the object being pointed at are const, we can modify either of them. Below, we update our object through the pointer, and then we update the pointer itself. Both are allowed:

int main() {
  int Number{5};
  int* Ptr{&Number};

  // We can modify the object
  (*Ptr)++;

  // We can modify the pointer
  Ptr = nullptr;
}

The pointer is const

When we mark the pointer as const, we cannot update it. However, we can still update the object it is pointing at. We mark the pointer as const by placing the const keyword after the * on the type:

int main() {
  int Number{5};
  int* const Ptr{&Number};

  // We can modify the object
  (*Ptr)++;

  // We can NOT modify the pointer
  Ptr = nullptr;
}

A pointer of this type is colloquially referred to as a "const pointer"

The object being pointed at is const

When the object being pointed at is const, we can’t update it through the pointer, but we can update what the pointer is pointing at. We mark the object as const by placing the const keyword at the start of the type:

int main() {
  int Number{5};
  const int* Ptr{&Number};

  // We can NOT modify the object
  (*Ptr)++;

  // We can modify the pointer
  Ptr = nullptr;
}

A pointer of this type is colloquially referred to as a "pointer to const"

Both the pointer and the object are const

Finally, it is possible for both the pointer and the object being pointed at to be const. We implement this using the const keyword twice - once before the * and once after:

int main() {
  int Number{5};
  const int* const Ptr{&Number};

  // We can NOT modify the object
  (*Ptr)++;

  // We can NOT modify the pointer
  Ptr = nullptr;
}

A pointer of this type is colloquially referred to as a "const pointer to const"

To summarise:

  • pointer to int: int* Num
  • const pointer to int: int* const Num
  • pointer to const int: const int* Num
  • const pointer to const int: const int* const Num

This syntax is very likely to be confusing, and nobody is expected to remember it. The important thing to remember is the concept - the pointer and the object being pointed at are two different things, and either of them can be const.

Placing const after the type (with pointers)

When we’re using the less common approach of placing the const qualifier after the type it applies to, a pointer to const would look like this:

// Equivalent to const int* Ptr;
int const* Ptr;

A const pointer to const would look like this:

// Equivalent to const int* const Ptr;
int const* const Ptr;

const Return Types

Function return types can be marked as const. However, there are some implications here we should be aware of. We can imagine the caller will receive the objects returned by functions by value, so they will receive copies of the object.

Therefore, the value received by the caller will not be const, unless the caller marks them as const:

const int GetNumber() {
  return 1;
}

int main() {
  int A{GetNumber()};
  A++; // This is fine

  const int B{GetNumber()};
  B++; // This is not
}

And if the caller marks them const, they will be const regardless of what the function prototype declared. The net effect of this is that there is no reason to mark function return types as const.

Additionally, marking return types as const can impair performance, as it interferes with return value optimization - a compiler feature we’ll cover in detail in the next course.

Note this advice only applies to values returned from functions. It is reasonable for functions to return a constant reference if needed:

class Character {};

Character Player;

const Character& GetPlayer() {
  return Player;
}

int main() {
  const Character& PlayerPointer{GetPlayer()};
}

Similarly, returning a pointer to const can make sense:

class Character {};

Character Player;

const Character* GetPlayer() {
  return &Player;
}

int main() {
  const Character* PlayerPointer{GetPlayer()};
}

Compile Time Constants and constexpr

We can imagine there being two types of constant variables - those that are known when we build our software, and those that can't be known until our software runs.

Almost all of the variables we've seen so far have been known at compile time and could be compile-time constants. An example of a compile-time constant is something like this:

const float Gravity { 9.8 };

Many other variables cannot be known until the program is run. Examples of these variables might include:

  • the time the application was launched
  • information about the user's hardware
  • anything that is based on user input

The more things that can be done at compile time, the more performant our software will be. Compilers can often detect when our code is creating compile-time constants, and optimize them for us automatically.

However, we can make sure that a variable is a compile-time constant (and have the compiler alert us if it doesn't seem to be) by using the constexpr (short for constant expression) keyword.

constexpr float Gravity { 9.8 };

When to avoid using const

Even though there are many situations where we can use const, it's not always a good idea.

As we covered above, there's no reason to use const for a value returned by a function:

const int Add(int x, int y) {
  return x + y;
}

Due to the way values are returned from functions, the use of const in that context does not behave as we might expect. It can also degrade performance.

Also, const is less important for arguments that are passed by value. Passing by value means the incoming data is copied. Callers of a function are generally not going to care about what happens to a copy of their data.

As such, it is not particularly important whether we mark these as const. Below, both our parameters could be const, but many would argue it would just be adding noise to our code:

int Add(int x, int y) {
  return x + y;
}

Similarly, it may not be worth marking a simple local variable as const. In the following example, Damage could be const, but the variable is discarded after two lines of code:

void TakeDamage() {
  int Damage { isImmune ? 0 : 100 };
  Health -= Damage;
}

Different people and companies will have different opinions on these topics.

The Unreal coding standard asks for const to be used anywhere it is applicable:

Const is documentation as much as it is a compiler directive, so all code should strive to be const-correct. ... Const should also be preferred on by-value function parameters and locals. ... Never use const on a return type. […] This rule only applies to the return type itself, not the target type of a pointer or reference being returned.

Google's style guide is less prescriptive, suggesting we "use const whenever it makes sense":

  • they encourage const to be used for function parameters that are pass-by-reference
  • they encourage const to be used for class methods that do not modify the object
  • they are indifferent to the use of const for local variables
  • they discourage const for function parameters that are passed by value

Removing const with const_cast

Whilst we should try to make our code const correct, we're inevitably going to find ourselves interacting with other code that isn't.

When this happens, we'd ideally want to update that other code, but that isn't always an option. For example, that code might be in a 3rd party library that we're not able to modify.

This can leave us in a situation where we know a function isn’t going to modify a parameter, but they have neglected to mark it as const:

// Assume we can't change the code in this
namespace SomeLibrary {
  // This function doesn't modify Input,
  // but has not marked it as const
  float LogHealth(Character& Input) {
    std::cout << "Health: "
      << Input.GetHealth();
  }
}

Meanwhile, our const-correct code wants to use this function, but the compiler will prevent us from doing so because we can’t create a non-const reference from a const reference::

#include <iostream>

class Character {/*...*/};
namespace SomeLibrary {/*...*/}; void Report(const Character& Input) { SomeLibrary::LogHealth(Input); } int main() { Character Player; Report(Player); }
error: 'float SomeLibrary::LogHealth(Character &)':
cannot convert argument 1 from 'const Character' to 'Character &'

For these situations, we have const_cast. This will remove the "constness" of a reference or the value pointed to by a pointer.

int x { 5 }

const int* Pointer { &x };
int* NonConstPointer {
  const_cast<int*>(Pointer)
};

const int& Reference { x };
int& NonConstReference {
  const_cast<int&>(Reference)
};

We can update our previous program to make use of this:

#include <iostream>

class Character {/*...*/};
namespace SomeLibrary {/*...*/}; void Report(const Character& Input) { SomeLibrary::LogHealth( const_cast<Character&>(Input) ); } int main() { Character Player; Report(Player); }
Health: 100

Adding const using const_cast

const_cast can also be used to add constness to a reference or pointer:

int x { 5 };
int& Reference { x };

const int& ConstantReference {
  const_cast<const int&>(Reference)
};

However, this is generally not useful. We've already seen how a non-const reference can be implicitly converted to a const reference.

mutable Members

Within our classes, we can mark some variables as mutable. Mutable variables can be modified by const member functions.

The use case for this is inherently quite niche and, oftentimes, using it at all indicates a flaw with our design. This is particularly true if we're marking variables that are core to the functionality of our class mutable.

However, it can sometimes be helpful for scenarios where our objects carry ancillary data that is not part of the public interface.

Below, we would like to keep track of how many times consumers have checked the health of our object, so we introduce a private member variable and increment it from GetHealth():

class Character {
public:
  int GetHealth() const {
    HealthRequests++;
    return Health;
  }
private:
  int Health { 100 };
  int HealthRequests { 0 };
};

Naturally, the compiler prevents this, as GetHealth() is const, and we can’t modify our object from a const function:

error  'HealthRequests' cannot be modified because it is being accessed through a const object

We could make GetHealth() non-const, but that could be quite disruptive, requiring us to make lots of Character references around our code base non-const too.

Additionally, it would be a confusing change for consumers. Whilst GetHealth() is indeed modifying the object, it’s not modifying it in a way that consumers should ever care about.

So, instead, we can simply mark the HealthRequests variable as mutable:

class Character {
public:
  int GetHealth() const {
    HealthRequests++;
    return Health;
  }
private:
  int Health { 100 };
  mutable int HealthRequests { 0 };
};

Summary

This lesson introduces the concept of const-correctness, exploring how const can be used with variables, functions, and pointers to prevent unintended modifications.

Key Takeaways:

  • Understanding the difference between pass-by-value and pass-by-reference, and how const can be used to avoid modifications to the original object when passed by reference.
  • Learning about const member functions and how they ensure member methods do not modify the object, allowing their use with const objects.
  • Differentiating between const pointers and const objects, including various combinations like const pointer to const and const pointer to non-const.
  • Grasping the concept of compile-time constants (constexpr) versus runtime constants (const) and their respective uses.
  • Recognizing appropriate contexts for using const and understanding advanced concepts like const_cast and mutable members.

Preview of the Next Lesson

In the upcoming lesson, we will delve into some commenting best practices. Additionally, we'll introduce comment formats like Javadoc, which makes our comments more powerful.

Key Topics Covered:

  • Learning best practices for writing effective comments.
  • Techniques for avoiding over-commenting and maintaining a balance between code and comments.
  • Practical examples of how to apply commenting best practices in real-world coding scenarios.
  • Introduction to Javadoc-style commenting and its benefits.

Was this lesson useful?

Edit History

  • Refreshed Content

  • First Published

Ryan McCombe
Ryan McCombe
Edited
3D art showing a progammer setting up a development environment
This lesson is part of the course:

Intro to C++ Programming

Become a software engineer with C++. Starting from the basics, we guide you step by step along the way

Free, Unlimited Access
Clean Code
Next Lesson

Effective Comments and Javadoc

This lesson provides an overview of effective commenting strategies, including Javadoc style commenting for automated documentation
3D art showing a fantasy maid character
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved