Search⌘ K
AI Features

Classes, Encapsulation, and Access Control

Explore the fundamental concepts of classes, encapsulation, and access control in C++. Learn how to safeguard data by hiding internal state with private members and providing controlled access through public methods. Understand how these practices maintain object validity and prevent errors as your programs grow.

Up until now, we have learned how to store values in variables and change them using functions. This works for small programs, but it becomes unsafe as programs grow. A variable can be changed to any value, even an invalid one, because nothing protects it. This can lead to mistakes and inconsistent data. To avoid this, we need a way to keep data and the code that controls it together in one place. This idea is called object-oriented programming (OOP). In C++, we do this using a class.

How to create an object in C++ using Structs

Before we jump into powerful objects, let's look at how to create an object in C++ using the simplest method: the struct (structure).

A struct is a user-defined type that bundles related variables together. By default, everything inside a struct is public, which means anyone can read or write the data directly.

Let's build a simple banking system.

C++ 23
#include <iostream>
#include <string>
struct BankAccount {
std::string owner;
double balance;
}; // Semicolon is required!
int main() {
// 1. This is how to create an object in C++.
BankAccount account;
// 2. We can access members directly because they are public by default
account.owner = "Alice";
account.balance = 100.0;
std::cout << account.owner << " has $" << account.balance << "\n";
// THE PROBLEM: No safety. We can break the logic easily
account.balance = -5000.0; // Invalid state!
std::cout << "Broken Balance: " << account.balance << "\n";
return 0;
}

Let’s break this down into steps:

  • Lines 4–7: We define the BankAccount type. It holds two variables.

  • Lines 14–15: We modify the data directly using the dot operator (.).

  • Line 20: This is the danger. We set balance to a negative number, which shouldn't be possible for this type of account. The struct cannot stop us.

From structs to classes

To prevent the "broken balance" issue, we need encapsulation. Encapsulation is the practice of hiding internal data and restricting access to it.

In C++, we use the keyword class to define types that enforce encapsulation.

  • struct: Members are public by default.

  • class: Members are private by default.

If we simply change the keyword struct to class in our example, the compiler will immediately stop us from making mistakes.

C++ 23
#include <iostream>
#include <string>
class BankAccount {
std::string owner; // Private by default
double balance; // Private by default
};
int main() {
BankAccount account;
account.balance = 100.0; // COMPILER ERROR: 'balance' is private
return 0;
}

The compiler now protects the data. main() cannot touch balance directly. This is good for safety, but now our object is useless because we can't use it at all! We need a way to grant controlled access.

Access specifiers: The public interface

We control visibility using access specifiers. These are labels that divide the class into sections:

  1. public: The interface is accessible from anywhere. We put functions here that allow users to interact with the object safely.

  2. private: The implementation is accessible only from inside the class. We keep our data here.

Let's evolve our BankAccount to allow deposits while keeping the balance hidden.

C++ 23
#include <iostream>
#include <string>
class BankAccount {
private:
// Data is hidden. Only this class can touch it
std::string owner;
double balance = 0.0; // In-class initialization (good practice)
public:
// Setup function (we'll replace this with Constructors in the next lesson)
void init(std::string name, double startAmount) {
owner = name;
if (startAmount > 0) balance = startAmount;
}
// Public Interface: Controlled access
void deposit(double amount) {
if (amount > 0) {
balance += amount;
std::cout << "Deposited $" << amount << "\n";
}
}
// Getter: Read-only access to the balance
double getBalance() {
return balance;
}
};
int main() {
BankAccount account;
account.init("Alice", 100.0); // Use public function to setup
account.deposit(50.0); // Use public function to modify
// account.balance = -500; // ERROR: Still private!
std::cout << "Current Balance: $" << account.getBalance() << "\n";
return 0;
}

Let’s break this down into steps:

  • Lines 5–8: The private section holds the raw data.

  • Lines 10–28: The public section defines how the world interacts with the bank account.

  • Line 19: We added logic! The deposit function checks if (amount > 0). We can no longer accidentally add negative money.

Enforcing correctness (invariants)

The rule "balance must not be negative" is called an invariant, a condition that must always be true for the object to be valid.

By forcing all changes to go through public functions (setters or action methods), we ensure the invariant is never broken. Let's add a withdraw method to our class that prevents overdrafting.

C++ 23
#include <iostream>
#include <string>
class BankAccount {
private:
std::string owner;
double balance = 0.0;
public:
void init(std::string name, double startAmount) {
owner = name;
if (startAmount > 0) balance = startAmount;
}
void deposit(double amount) {
if (amount > 0) {
balance += amount;
}
}
// The Logic: Enforcing the "No Negative Balance" rule
void withdraw(double amount) {
if (amount > balance) {
std::cout << "Error: Insufficient funds for withdrawal of $" << amount << "\n";
} else if (amount <= 0) {
std::cout << "Error: Invalid withdrawal amount.\n";
} else {
balance -= amount;
std::cout << "Success: Withdrew $" << amount << "\n";
}
}
double getBalance() { return balance; }
};
int main() {
BankAccount account;
account.init("Alice", 100.0);
account.withdraw(40.0); // OK: Balance becomes 60.0
account.withdraw(500.0); // BLOCKED: The class protects itself
std::cout << "Final Balance: $" << account.getBalance() << "\n";
return 0;
}

Let’s understand the addition:

  • Lines 22–31: The withdraw function acts as a gatekeeper. It checks the math before updating the private variable balance.

  • Line 41: The user tries to break the rule (withdraw more than they have).

  • Line 24: The class catches this attempt and prevents the modification. The object remains valid.

Designing for clarity: Private helpers

Finally, we can also make functions private. If a function is only used internally by the class (like a helper), it should not be exposed to the user. This keeps the public interface clean and simple.

Let's finalize our BankAccount by adding a private logTransaction helper:

C++ 23
#include <iostream>
#include <string>
#include <vector>
class BankAccount {
private:
std::string owner;
double balance = 0.0;
std::vector<std::string> history; // We also hide the history log
// INTERNAL HELPER: The user doesn't need to call this directly
void logTransaction(std::string msg) {
history.push_back(msg);
std::cout << "[LOG]: " << msg << "\n";
}
public:
void init(std::string name, double startAmount) {
owner = name;
balance = startAmount;
logTransaction("Account created for " + name);
}
void deposit(double amount) {
if (amount > 0) {
balance += amount;
// We use the private helper here
logTransaction("Deposited " + std::to_string(amount));
}
}
void withdraw(double amount) {
if (amount <= balance && amount > 0) {
balance -= amount;
// We use the private helper here
logTransaction("Withdrew " + std::to_string(amount));
}
}
double getBalance() { return balance; }
};
int main() {
BankAccount account;
account.init("Alice", 100.0); // Logs creation automatically
account.deposit(50.0); // Logs deposit automatically
// account.logTransaction("Fake Log"); // ERROR: Private function!
return 0;
}

Let’s understand these step by step:

  • Lines 12–15: logTransaction is private. It handles the details of updating the vector and printing.

  • Lines 21 and 28: The public methods deposit and withdraw call the private helper.

Note that the user of BankAccount doesn't need to know how logging works, or even that it exists. They just call deposit, and the class handles the rest responsibly.

We have moved from raw structs, where data is exposed and fragile, to classes, where data is encapsulated and safe. By making members private, we force all interactions to go through a controlled public interface, ensuring our objects never enter an invalid state.

However, in all our examples, we had to call a manual .init() function to set up our object. If we forgot to call it, our data might be incorrect. In the next lesson, we will learn about constructors, which automate this setup process the moment an object is created.