📝 Lesson 12: Const Correctness
Writing safer, more predictable code by making deliberate promises about data mutability.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Understand the purpose and benefits of const correctness in C++.
- Apply const to variables, pointers, and function parameters.
- Implement const member functions and understand their role in API design.
- Distinguish between const pointers, pointers to const, and const pointers to const.
- Use mutable and const_cast appropriately when needed.
Estimated Time: 60–80 minutes
Project: Improve a basic Player class by applying const-correctness to its interface and members.
In This Lesson
What is Const Correctness?
Const correctness is like putting safety locks on your code. It's a promise to the compiler (and other developers) about what your code will and won't modify. Think of it as labeling boxes "fragile" or "do not open" - it prevents accidents and makes intentions clear.
Const Variables and Parameters
The simplest use of const is to create read-only variables and function parameters that won't be modified.
Basic Const Usage
// 1. Const Variables
void ConstVariables() {
constexpr int MaxHealth = 100; // Known at compile time (implicitly const)
constexpr float Pi = 3.14159f; // Compile-time constant
// MaxHealth = 200; // ERROR: Cannot modify const variable
// Const with a value only known at run time - use const, not constexpr
const int Level = CalculateLevel(); // Set once, never changed
// Arrays
const int Scores[3] = {100, 95, 87};
// Scores[0] = 99; // ERROR: Cannot modify const array
}
// 2. Const Parameters - Pass by Value
void ProcessScore(const int score) {
// score = 100; // ERROR: Cannot modify const parameter
std::cout << "Score: " << score << std::endl;
}
// 3. Const References - Very Common!
void PrintVector(const FVector& Location) {
// Location.X = 0; // ERROR: Cannot modify through const reference
UE_LOG(LogTemp, Log, TEXT("Location: %s"), *Location.ToString());
}
// 4. Const Pointers - Multiple meanings!
void ConstPointers() {
int value = 42;
int other = 99;
// Pointer to const int (data is const)
const int* ptr1 = &value;
// *ptr1 = 43; // ERROR: Cannot modify data
ptr1 = &other; // OK: Can change pointer
// Const pointer to int (pointer is const)
int* const ptr2 = &value;
*ptr2 = 43; // OK: Can modify data
// ptr2 = &other; // ERROR: Cannot change pointer
// Const pointer to const int (both const)
const int* const ptr3 = &value;
// *ptr3 = 43; // ERROR: Cannot modify data
// ptr3 = &other; // ERROR: Cannot change pointer
}
// 5. Const References vs Copies
class ExpensiveObject {
std::vector<int> BigData;
public:
// Bad: Unnecessary copy
void ProcessSlow(ExpensiveObject obj) { }
// Good: Const reference - no copy!
void ProcessFast(const ExpensiveObject& obj) { }
};
const vs. constexpr, consteval, and constinit
const only promises "I won't change this after it's set" — the value can still be computed at run time. Modern C++ adds three related keywords that talk about when a value is produced. They're worth knowing because Unreal engine code (and the standard library) uses them heavily.
const int a = CalculateLevel(); // read-only, but computed at run time
constexpr int b = 3 * 4; // usable at compile time; implicitly const
// consteval (C++20): an "immediate" function - every call must be
// evaluated during compilation.
consteval int Square(int n) { return n * n; }
constexpr int c = Square(8); // OK: folded to 64 at compile time
// int runtime = ReadFromDisk();
// int d = Square(runtime); // ERROR: consteval needs a constant argument
// constinit (C++20): guarantees a static/global is INITIALIZED at compile
// time (avoiding the static-initialization-order fiasco). Note: it does NOT
// make the variable const - the object is still mutable afterwards.
constinit int gFrameCounter = 0;
// gFrameCounter++; // Fine later: constinit constrains init, not mutation
Rule of thumb: reach for constexpr when a value can be known at compile time, consteval when it must be, and constinit only to pin down how a static/global gets initialized.
Const Member Functions
Const member functions promise not to modify the object they're called on. They're like "read-only" operations on your class.
Implementing Const Member Functions
class Character {
private:
float Health;
int Level;
mutable int CacheVersion; // Can be modified in const functions
public:
// Const member functions - promise not to modify object
float GetHealth() const {
// Health = 100; // ERROR: Cannot modify in const function
return Health;
}
int GetLevel() const { return Level; }
// Const function returning const reference
const FString& GetName() const { return Name; }
// Non-const member functions - can modify object
void TakeDamage(float Damage) {
Health -= Damage; // OK: Non-const function can modify
}
// Overloading based on const
// Non-const version - returns modifiable reference
FVector& GetPosition() { return Position; }
// Const version - returns const reference
const FVector& GetPosition() const { return Position; }
// Mutable members can be modified in const functions
void UpdateCache() const {
CacheVersion++; // OK: mutable member
}
// Const functions can call other const functions
bool IsAlive() const {
return GetHealth() > 0; // OK: Calling const function
}
// ERROR: Const function cannot call non-const function
void PrintStatus() const {
// TakeDamage(0); // ERROR: Cannot call non-const from const
}
};
// Const reference parameters - a const& binds to both const and non-const
// objects and promises not to modify them, so only const members are callable
void ProcessCharacter(const Character& ch) {
float hp = ch.GetHealth(); // OK: const function
// ch.TakeDamage(10); // ERROR: non-const function
}
// Usage with const objects
void UseConstObject() {
const Character ConstHero;
Character MutableHero;
// Const object can only call const functions
float hp1 = ConstHero.GetHealth(); // OK
// ConstHero.TakeDamage(10); // ERROR
// Non-const object can call both
float hp2 = MutableHero.GetHealth(); // OK
MutableHero.TakeDamage(10); // OK
// Pass either object to a const& parameter
ProcessCharacter(ConstHero); // OK
ProcessCharacter(MutableHero); // OK
}
Const with Pointers and References
Understanding const with pointers is like learning grammar rules - there are specific patterns that, once learned, make everything clear.
// The Const Pointer Rules - Read Right to Left!
// 1. Pointer to const data
const int* ptr1; // ptr1 is a pointer to const int
int const* ptr2; // Same as above (alternative syntax)
// Can change pointer, cannot change data
// 2. Const pointer to data
int* const ptr3 = &value; // ptr3 is a const pointer to int
// Cannot change pointer, can change data
// 3. Const pointer to const data
const int* const ptr4 = &value; // ptr4 is a const pointer to const int
// Cannot change pointer or data
// Real-world Examples
class Game {
// Return const pointer - caller can't modify data
const Player* GetPlayer(int id) const {
return Players[id];
}
// Return non-const pointer - caller can modify
Player* GetMutablePlayer(int id) {
return Players[id];
}
// Const reference - very common for parameters
void AddScore(const FString& PlayerName, int Score) {
// PlayerName cannot be modified
Scores[PlayerName] += Score;
}
// Multiple const meanings in one declaration
const int* const GetScores() const {
// Returns const pointer to const data from const function!
return &Scores[0];
}
};
// Const with Smart Pointers
void SmartPointerConst() {
// Const unique_ptr - pointer is const, data is not
const std::unique_ptr<Player> p1 = std::make_unique<Player>();
p1->SetHealth(100); // OK: Can modify Player
// p1.reset(); // ERROR: Cannot change pointer
// Unique_ptr to const - data is const, pointer is not
std::unique_ptr<const Player> p2 = std::make_unique<Player>();
// p2->SetHealth(100); // ERROR: Cannot modify const Player
p2.reset(); // OK: Can change pointer
// Both const
const std::unique_ptr<const Player> p3 = std::make_unique<Player>();
// p3->SetHealth(100); // ERROR: Cannot modify data
// p3.reset(); // ERROR: Cannot change pointer
}
Const Cast: Breaking Const (Carefully!)
Const cast is like having a master key - use it only when absolutely necessary and you know it's safe.
Using const_cast Safely
// Safe const_cast usage
// 1. Working with legacy C APIs
void LegacyAPI(char* str); // Old C function that doesn't use const
void CallLegacyAPI(const std::string& str) {
// We know LegacyAPI won't modify, but it needs non-const
LegacyAPI(const_cast<char*>(str.c_str()));
}
// 2. Implementing const and non-const versions
class Container {
private:
std::vector<int> data;
public:
// Const version holds the real implementation
const int& operator[](size_t index) const {
return data[index];
}
// Non-const version delegates to the const one to avoid duplication.
// std::as_const forces the const overload; casting away const on the
// RESULT is safe here because we know *this is genuinely non-const.
int& operator[](size_t index) {
return const_cast<int&>(std::as_const(*this)[index]);
}
// (The reverse - a const function calling the non-const one - would be
// the unsafe way round: it could modify an object that is truly const.)
};
// 3. Mutable-like behavior before C++11
class OldCache {
private:
mutable bool CacheValid; // Modern way
int CachedValue;
public:
int GetValue() const {
if (!CacheValid) {
// Had to use const_cast before mutable keyword
const_cast<OldCache*>(this)->RecalculateCache();
}
return CachedValue;
}
private:
void RecalculateCache() { /* ... */ }
};
// DANGEROUS const_cast usage - AVOID!
void DangerousConstCast() {
const int ConstValue = 42;
int* ptr = const_cast<int*>(&ConstValue);
*ptr = 99; // UNDEFINED BEHAVIOR! Modifying const object
// String literal - NEVER do this!
const char* str = "Hello";
char* mutable_str = const_cast<char*>(str);
mutable_str[0] = 'h'; // CRASH! String literals are read-only
}
// When you SHOULD NOT use const_cast
void FixConstCorrectness(const Object& obj) {
// BAD: Don't cast away const to "fix" design issues
// const_cast<Object&>(obj).Modify();
// GOOD: Fix the design instead
// - Make the parameter non-const if modification is needed
// - Use mutable for cache-like members
// - Redesign the interface
}
Const in Real-World Code
Let's see how const correctness improves real game development code.
Game Development Example
// Well-designed game class with const correctness
class AGameCharacter : public AActor {
private:
// Immutable configuration - shared by every instance, known at compile time
static constexpr float MaxHealth = 100.0f;
static constexpr float MaxSpeed = 600.0f;
// Mutable state
float CurrentHealth;
FVector Velocity;
// Cached data (mutable for const functions)
mutable bool bStatsCalculated;
mutable FCharacterStats CachedStats;
public:
// Const-correct getters. [[nodiscard]] makes the compiler warn if a caller
// ignores the returned value - handy for pure "read" functions like these.
[[nodiscard]] float GetHealth() const { return CurrentHealth; }
[[nodiscard]] float GetMaxHealth() const { return MaxHealth; }
[[nodiscard]] float GetHealthPercent() const { return CurrentHealth / MaxHealth; }
// Return const reference for efficiency
[[nodiscard]] const FVector& GetVelocity() const { return Velocity; }
// Non-const reference for modification
FVector& GetMutableVelocity() { return Velocity; }
// Const-correct parameters
void ApplyDamage(const FDamageEvent& DamageEvent) {
// DamageEvent is read-only
CurrentHealth -= DamageEvent.DamageAmount;
}
// Const member function with mutable cache
const FCharacterStats& GetStats() const {
if (!bStatsCalculated) {
CalculateStats(); // Updates mutable cache
bStatsCalculated = true;
}
return CachedStats;
}
// Comparison operators should be const
bool operator==(const AGameCharacter& Other) const {
return GetName() == Other.GetName();
}
// Iterator-style access
TArray<AWeapon*>::TConstIterator GetWeaponsIterator() const {
return Weapons.CreateConstIterator();
}
private:
void CalculateStats() const {
// Can modify mutable members in const function
CachedStats.Attack = CalculateAttack();
CachedStats.Defense = CalculateDefense();
}
};
// Using const in APIs
class UGameplayStatics {
public:
// Const world parameter - won't modify world
static AActor* SpawnActor(const UWorld* World,
UClass* Class,
const FTransform& Transform);
// Const array reference - efficient, read-only
static float CalculateAverageDamage(const TArray<float>& DamageValues);
// Multiple const qualifiers for safety
static const APlayerController* GetFirstPlayerController(const UWorld* World);
};
Common Const Pitfalls
Understanding common mistakes helps you write better const-correct code from the start.
// Pitfall 1: Forgetting const on getters
class Bad1 {
int value;
public:
int GetValue() { return value; } // Should be const!
};
class Good1 {
int value;
public:
int GetValue() const { return value; } // Correct
};
// Pitfall 2: Returning non-const reference to members
class Bad2 {
std::vector<int> data;
public:
std::vector<int>& GetData() const { return data; } // ERROR!
};
class Good2 {
std::vector<int> data;
public:
const std::vector<int>& GetData() const { return data; } // Correct
};
// Pitfall 3: Const object with non-const members
struct Bad3 {
int* ptr; // Pointer member
};
void UseBad3() {
const Bad3 obj = {new int(42)};
*obj.ptr = 99; // Modifies pointed-to data despite const obj!
}
// Pitfall 4: Logical vs Physical constness
class String {
char* data;
mutable size_t cachedLength;
public:
// Logically const - doesn't change visible state
size_t Length() const {
if (cachedLength == 0) {
cachedLength = strlen(data); // OK: mutable
}
return cachedLength;
}
};
// Pitfall 5: Const in wrong place
const int* Function1(); // Returns pointer to const int
int* const Function2(); // Returns const pointer to int
int const* Function3(); // Same as Function1 (alternative syntax)
// Pitfall 6: East const vs West const
// Both are valid, be consistent!
const int* p1; // West const (more common)
int const* p2; // East const (arguably clearer)
Practice Exercise: const Correctness
🏋️ Challenge: Make This Code const-correct
The following code lacks proper const usage. Add const wherever appropriate:
// Fix this code by adding const appropriately
class Player {
private:
std::string name;
int health;
int maxHealth;
std::vector<Item> inventory;
Position currentPosition;
bool isDead;
public:
Player(std::string playerName, int startHealth)
: name(playerName), health(startHealth), maxHealth(startHealth) {}
std::string GetName() { return name; }
int GetHealth() { return health; }
bool IsDead() { return health <= 0; }
Position& GetPosition() { return currentPosition; }
void TakeDamage(int damage) {
health -= damage;
if (health < 0) health = 0;
}
void AddItem(Item item) {
inventory.push_back(item);
}
std::vector<Item> GetInventory() {
return inventory;
}
void PrintStats() {
std::cout << "Player: " << name << " HP: " << health << "/" << maxHealth;
}
};
// Function that should use const
void DisplayPlayerInfo(Player player) {
std::cout << player.GetName() << " has " << player.GetHealth() << " HP\n";
}
// Areas to fix:
// 1. Constructor parameters
// 2. Getter methods
// 3. Member functions that don't modify
// 4. Return types (copies vs references)
// 5. Function parameters
✅ Solution
// const-correct version
class Player {
private:
std::string name;
int health;
int maxHealth;
std::vector<Item> inventory;
Position currentPosition;
bool isDead;
public:
Player(const std::string& playerName, int startHealth)
: name(playerName), health(startHealth), maxHealth(startHealth) {}
// 2 & 3: Getters and non-modifying functions marked const
const std::string& GetName() const { return name; }
int GetHealth() const { return health; }
bool IsDead() const { return health <= 0; }
// Const overload lets const Players read position; non-const lets others modify it
const Position& GetPosition() const { return currentPosition; }
Position& GetPosition() { return currentPosition; }
void TakeDamage(int damage) {
health -= damage;
if (health < 0) health = 0;
}
// 5: Take the item by const reference to avoid an unnecessary copy
void AddItem(const Item& item) {
inventory.push_back(item);
}
// 4: Return a const reference instead of a full copy of the vector
const std::vector<Item>& GetInventory() const {
return inventory;
}
void PrintStats() const {
std::cout << "Player: " << name << " HP: " << health << "/" << maxHealth;
}
};
// 5: Pass by const reference - no copy, and the function's intent is clear
void DisplayPlayerInfo(const Player& player) {
std::cout << player.GetName() << " has " << player.GetHealth() << " HP\n";
}
🎯 Quick Quiz
Question 1: What does placing const after a member function's parameter list, as in int GetHealth() const { return Health; }, mean?
Question 2: Given const int* ptr = &value;, which statement is true?
Question 3: What is the purpose of the mutable keyword on a class member?
Summary
🎉 Key Takeaways
- Use
constfor variables that shouldn't change - Mark member functions
constif they don't modify the object - Pass large objects by
const&, not by value, to avoid unnecessary copies - Return const references to member data when appropriate
- Remember:
const int*andint* consthave different meanings — read pointer declarations right to left - Use
mutablefor cache-like members that need updates inside const functions - Prefer
constexprfor compile-time constants;constevalforces compile-time evaluation andconstinitpins down static initialization (both C++20) - Mark pure read-only accessors
[[nodiscard]]so callers can't silently ignore the result - Avoid
const_castunless absolutely necessary — fix the design instead - Be consistent with const placement (east vs west const)
- Const correctness prevents bugs and clarifies intent for every caller of your API
📚 Additional Resources
- cppreference — const/volatile type qualifiers
- cppreference — const_cast conversion
- isocpp.org — Const Correctness FAQ
🚀 What's Next?
You now know how to make deliberate promises about what your code will and won't modify. In Lesson 13: Operator Overloading, you'll put that same discipline to work again — giving your classes natural, intuitive operators like ==, +, and [], many of which should themselves be marked const, exactly as you practiced here.
🎉 Const-correct and confident!
You've learned one of the habits that separates careful C++ developers from the rest. Keep making those promises — your compiler (and your teammates) are already thanking you.