A bank account’s balance should never go below zero. If balance is a public field, every line of code that can see an account can also break that rule, and finding the line that did means searching the whole program. If the balance is private and changes only through deposit and withdraw, the rule is checked in one place, for every caller. That is encapsulation.
This guide explains encapsulation as an object-oriented concept, with the same examples in C++, Java and Python: how private state and public methods protect an object’s rules, how a getter can quietly hand those rules away, why getters and setters alone are not encapsulation, and how Python achieves the same thing by convention. Every program was run for this article on Ubuntu 24.04 with GCC 13.3 and Clang 18.1.3 (-std=c++17 -Wall -Wextra -pedantic -Werror), OpenJDK 21 (javac -Xlint:all -Werror) and Python 3.11, 3.12 and 3.13 (-W error), and every output below is captured verbatim. The code is in a GitHub repository whose build repeats these checks on each commit.
The Short Answer
What is encapsulation in OOP? Bundling an object’s data with the methods that operate on it, and allowing outside code to change that data only through those methods. The methods enforce the object’s rules, so every object stays valid however callers use those methods.
Is encapsulation the same as data hiding? No, but they are closely related. Data hiding (making fields private) is the main tool encapsulation uses. Encapsulation is the goal: one class owns its data and decides how that data may change.
Are getters and setters encapsulation? Not by themselves. A setter that accepts any value protects nothing a public field would not. Encapsulation comes from methods that enforce rules, such as withdraw refusing to overdraw an account.
What Is Encapsulation?
Encapsulation in object-oriented programming is the practice of keeping an object’s state and the code that changes it together in one class, and restricting direct access to that state from outside. Outside code works with the object only through its public methods, which check every change against the object’s rules (its invariants), so code that uses the object through that interface cannot put it into an invalid state.
An invariant is a condition that holds for every object of a class whenever outside code can observe it. For the account below, the invariants are that the balance is never negative and that the history of transactions adds up to the balance. Encapsulation is what makes an invariant enforceable: if only the class’s own code can change the balance, then only the class’s own code needs to be checked.
Encapsulation has three parts:
| Part | What it means | In the account example |
|---|---|---|
| Bundle | Data and the operations on it live in one class | balance and history sit next to deposit and withdraw |
| Restrict | Outside code cannot reach the data directly | The fields are private (C++, Java) or marked private by convention (Python) |
| Enforce | Every change goes through a method that checks the rules | withdraw refuses amounts above the balance and amounts below 1 |
The idea predates object-oriented languages. David Parnas’s 1972 paper “On the Criteria To Be Used in Decomposing Systems into Modules” argued that each module should hide a design decision behind an interface, so the decision can change without breaking the code that uses it. Classes with private members are the most familiar way of applying that principle today.
Encapsulation in C++, Java and Python
The same account class in all three languages. The balance can change only through deposit and withdraw, and the history is available to callers only as something they cannot modify:
C++:
// account.cpp - encapsulation: the balance can change only through
// deposit() and withdraw(), which never let it go below zero.
#include <iostream>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
class Account {
public:
Account(std::string owner, long opening) : owner_(std::move(owner)) {
deposit(opening);
}
void deposit(long amount) {
require_positive(amount);
balance_ += amount;
history_.push_back(amount);
}
void withdraw(long amount) {
require_positive(amount);
if (amount > balance_) {
throw std::invalid_argument("insufficient funds: balance " + std::to_string(balance_) +
", requested " + std::to_string(amount));
}
balance_ -= amount;
history_.push_back(-amount);
}
const std::string& owner() const { return owner_; }
long balance() const { return balance_; }
const std::vector<long>& history() const { return history_; } // read-only to callers
private:
static void require_positive(long amount) {
if (amount <= 0) {
throw std::invalid_argument("amount must be positive: " + std::to_string(amount));
}
}
std::string owner_;
long balance_ = 0;
std::vector<long> history_;
};
int main() {
Account acct("Ada", 100);
std::cout << acct.owner() << " opened with " << acct.balance() << '\n';
acct.deposit(50);
std::cout << "deposit 50 -> balance " << acct.balance() << '\n';
acct.withdraw(30);
std::cout << "withdraw 30 -> balance " << acct.balance() << '\n';
for (long amount : {500L, -5L}) {
try {
acct.withdraw(amount);
} catch (const std::invalid_argument& e) {
std::cout << "error: " << e.what() << '\n';
}
}
std::cout << "balance " << acct.balance() << '\n';
std::cout << "history:";
for (long t : acct.history()) {
std::cout << ' ' << (t > 0 ? "+" : "") << t;
}
std::cout << '\n';
// acct.balance_ = 1000000; // does not compile: balance_ is private
}
Java:
// Account.java - encapsulation: the balance can change only through
// deposit() and withdraw(), which never let it go below zero.
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class Account {
private final String owner;
private long balance = 0;
private final List<Long> history = new ArrayList<>();
public Account(String owner, long opening) {
this.owner = owner;
deposit(opening);
}
public void deposit(long amount) {
requirePositive(amount);
balance += amount;
history.add(amount);
}
public void withdraw(long amount) {
requirePositive(amount);
if (amount > balance) {
throw new IllegalArgumentException(
"insufficient funds: balance " + balance + ", requested " + amount);
}
balance -= amount;
history.add(-amount);
}
public String owner() { return owner; }
public long balance() { return balance; }
public List<Long> history() { return Collections.unmodifiableList(history); } // read-only view
private static void requirePositive(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("amount must be positive: " + amount);
}
}
public static void main(String[] args) {
Account acct = new Account("Ada", 100);
System.out.println(acct.owner() + " opened with " + acct.balance());
acct.deposit(50);
System.out.println("deposit 50 -> balance " + acct.balance());
acct.withdraw(30);
System.out.println("withdraw 30 -> balance " + acct.balance());
for (long amount : new long[] {500, -5}) {
try {
acct.withdraw(amount);
} catch (IllegalArgumentException e) {
System.out.println("error: " + e.getMessage());
}
}
System.out.println("balance " + acct.balance());
StringBuilder line = new StringBuilder("history:");
for (long t : acct.history()) {
line.append(' ').append(t > 0 ? "+" : "").append(t);
}
System.out.println(line);
}
}
Python:
"""account.py - encapsulation: the balance can change only through
deposit() and withdraw(), which never let it go below zero."""
class Account:
def __init__(self, owner: str, opening: int) -> None:
self._owner = owner
self._balance = 0
self._history: list[int] = []
self.deposit(opening)
def deposit(self, amount: int) -> None:
self._require_positive(amount)
self._balance += amount
self._history.append(amount)
def withdraw(self, amount: int) -> None:
self._require_positive(amount)
if amount > self._balance:
raise ValueError(f"insufficient funds: balance {self._balance}, requested {amount}")
self._balance -= amount
self._history.append(-amount)
@property
def owner(self) -> str:
return self._owner
@property
def balance(self) -> int:
return self._balance
@property
def history(self) -> tuple[int, ...]:
return tuple(self._history) # a read-only copy
@staticmethod
def _require_positive(amount: int) -> None:
if amount <= 0:
raise ValueError(f"amount must be positive: {amount}")
acct = Account("Ada", 100)
print(f"{acct.owner} opened with {acct.balance}")
acct.deposit(50)
print(f"deposit 50 -> balance {acct.balance}")
acct.withdraw(30)
print(f"withdraw 30 -> balance {acct.balance}")
for amount in (500, -5):
try:
acct.withdraw(amount)
except ValueError as e:
print(f"error: {e}")
print(f"balance {acct.balance}")
print("history:", " ".join(f"{t:+d}" for t in acct.history))
Output (identical for all three):
Ada opened with 100
deposit 50 -> balance 150
withdraw 30 -> balance 120
error: insufficient funds: balance 120, requested 500
error: amount must be positive: -5
balance 120
history: +100 +50 -30
The withdrawal of 500 and the withdrawal of -5 were both rejected, and the balance stayed at 120 in every language. Code that uses the public interface has no way to make it negative, because the interface reduces the balance only through withdraw.
What happens when outside code tries to write the balance directly differs by language. All three were checked:
| Language | Statement | Result |
|---|---|---|
| C++ | acct.balance_ = 1000000; | Compile error: GCC reports 'long int Account::balance_' is private within this context |
| Java (from another class) | acct.balance = 1000000; | Compile error: balance has private access in Account |
| Python | acct.balance = 1000000 | AttributeError: property 'balance' of 'Account' object has no setter |
| Python | acct._balance = 1000000 | Runs. The underscore marks the attribute as internal, but nothing enforces it |
The Java main method is inside Account only to keep the example in one file. Code inside a class can use its private fields, so the compile error appears when another class tries the same assignment.
Access Control Compared
Each language gives a different amount of help in restricting access:
| Aspect | C++ | Java | Python |
|---|---|---|---|
| Private to the class | private: | private | _name by convention; __name is renamed to _Class__name |
| Visible to subclasses | protected: | protected (also visible in the same package) | No language support; _name is the convention |
| Default for members | private in a class, public in a struct | Package-private: visible within the same package | Public |
| Checked when | Compile time | Compile time | Not checked; read-only properties raise errors at run time |
| Exceptions | friend functions and classes | Reflection, subject to the module system | Everything is reachable |
Java’s own tutorial gives a rule that works in all three languages: “Use private unless you have a good reason not to,” and avoid public fields except for constants (Controlling Access to Members of a Class). C++ describes the same split in its terms: public members form the interface of a class, private members form its implementation (cppreference: access specifiers). The syntax of each keyword is covered in classes in object-oriented programming.
How a Getter Breaks Encapsulation
Making a field private is not enough if a method hands it back to callers. A getter that returns a reference to an internal list gives every caller the power to change that list, bypassing every check the class makes. In the next program a team has a limit of three members, enforced by add. LeakyTeam returns its private list; Team returns something the caller cannot use to change the team:
C++:
// leak.cpp - returning a reference to private data hands it to the caller.
// LeakyTeam's limit of three members can be broken from outside; Team's cannot.
#include <cstddef>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>
constexpr std::size_t kLimit = 3;
class LeakyTeam {
public:
void add(const std::string& name) {
if (members_.size() == kLimit) throw std::length_error("team is full");
members_.push_back(name);
}
std::vector<std::string>& members() { return members_; } // the private vector itself
private:
std::vector<std::string> members_;
};
class Team {
public:
void add(const std::string& name) {
if (members_.size() == kLimit) throw std::length_error("team is full");
members_.push_back(name);
}
const std::vector<std::string>& members() const { return members_; } // read-only
private:
std::vector<std::string> members_;
};
int main() {
LeakyTeam leaky;
for (const char* n : {"Ana", "Ben", "Cy"}) leaky.add(n);
std::cout << "leaky: added Ana, Ben, Cy -> " << leaky.members().size() << " members\n";
leaky.members().push_back("Dee");
leaky.members().push_back("Eve");
std::cout << "leaky: caller appended twice -> " << leaky.members().size()
<< " members (limit is " << kLimit << ")\n";
Team team;
for (const char* n : {"Ana", "Ben", "Cy"}) team.add(n);
std::cout << "safe: added Ana, Ben, Cy -> " << team.members().size() << " members\n";
// team.members().push_back("Dee"); // does not compile: the reference is const
std::vector<std::string> mine = team.members(); // an explicit copy is the caller's own
mine.push_back("Dee");
std::cout << "safe: caller's copy has " << mine.size() << ", team still has "
<< team.members().size() << " members\n";
}
Output:
leaky: added Ana, Ben, Cy -> 3 members
leaky: caller appended twice -> 5 members (limit is 3)
safe: added Ana, Ben, Cy -> 3 members
safe: caller's copy has 4, team still has 3 members
Java:
// Leak.java - returning the private list hands it to the caller.
// LeakyTeam's limit of three members can be broken from outside; Team's cannot.
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class Leak {
static final int LIMIT = 3;
static final class LeakyTeam {
private final List<String> members = new ArrayList<>();
void add(String name) {
if (members.size() == LIMIT) throw new IllegalStateException("team is full");
members.add(name);
}
List<String> members() { return members; } // the private list itself
}
static final class Team {
private final List<String> members = new ArrayList<>();
void add(String name) {
if (members.size() == LIMIT) throw new IllegalStateException("team is full");
members.add(name);
}
List<String> members() { return Collections.unmodifiableList(members); } // read-only view
}
public static void main(String[] args) {
LeakyTeam leaky = new LeakyTeam();
for (String n : List.of("Ana", "Ben", "Cy")) leaky.add(n);
System.out.println("leaky: added Ana, Ben, Cy -> " + leaky.members().size() + " members");
leaky.members().add("Dee");
leaky.members().add("Eve");
System.out.println("leaky: caller appended twice -> " + leaky.members().size()
+ " members (limit is " + LIMIT + ")");
Team team = new Team();
for (String n : List.of("Ana", "Ben", "Cy")) team.add(n);
System.out.println("safe: added Ana, Ben, Cy -> " + team.members().size() + " members");
try {
team.members().add("Dee");
} catch (UnsupportedOperationException e) {
System.out.println("safe: add rejected (UnsupportedOperationException), team still has "
+ team.members().size() + " members");
}
}
}
Output:
leaky: added Ana, Ben, Cy -> 3 members
leaky: caller appended twice -> 5 members (limit is 3)
safe: added Ana, Ben, Cy -> 3 members
safe: add rejected (UnsupportedOperationException), team still has 3 members
Python:
"""leak.py - returning the private list hands it to the caller.
LeakyTeam's limit of three members can be broken from outside; Team's cannot."""
LIMIT = 3
class LeakyTeam:
def __init__(self) -> None:
self._members: list[str] = []
def add(self, name: str) -> None:
if len(self._members) == LIMIT:
raise ValueError("team is full")
self._members.append(name)
@property
def members(self) -> list[str]:
return self._members # the private list itself
class Team:
def __init__(self) -> None:
self._members: list[str] = []
def add(self, name: str) -> None:
if len(self._members) == LIMIT:
raise ValueError("team is full")
self._members.append(name)
@property
def members(self) -> tuple[str, ...]:
return tuple(self._members) # a read-only copy
leaky = LeakyTeam()
for n in ("Ana", "Ben", "Cy"):
leaky.add(n)
print(f"leaky: added Ana, Ben, Cy -> {len(leaky.members)} members")
leaky.members.append("Dee")
leaky.members.append("Eve")
print(f"leaky: caller appended twice -> {len(leaky.members)} members (limit is {LIMIT})")
team = Team()
for n in ("Ana", "Ben", "Cy"):
team.add(n)
print(f"safe: added Ana, Ben, Cy -> {len(team.members)} members")
try:
team.members.append("Dee")
except AttributeError:
print(f"safe: append rejected (AttributeError), team still has {len(team.members)} members")
Output:
leaky: added Ana, Ben, Cy -> 3 members
leaky: caller appended twice -> 5 members (limit is 3)
safe: added Ana, Ben, Cy -> 3 members
safe: append rejected (AttributeError), team still has 3 members
The first two lines are the same in every language: two calls to append or push_back on the returned list took the leaky team to five members, past a limit that add checks on every call. The class’s rule held only for callers who chose to use add.
The safe versions protect the list in different ways, which is why the last line differs:
- C++ returns a
constreference. Callingpush_backon it does not compile, so the attempt is a comment in the program; a caller that wants to change the list must copy it first, and changing the copy leaves the team alone. - Java returns
Collections.unmodifiableList(members), a read-only view: it reflects later changes to the team, and any attempt to modify it throwsUnsupportedOperationException(theunmodifiableListentry in the JavaCollectionsdocumentation). - Python returns a tuple, a copy that has no
appendmethod, so the attempt raisesAttributeError.
| Need | C++ | Java | Python |
|---|---|---|---|
| Read-only access to the live data | Return const std::vector<T>& (or std::span<const T> in C++20) | Collections.unmodifiableList(list) | No read-only list type; return a tuple copy, or an iterator |
| An independent snapshot | Return std::vector<T> by value | List.copyOf(list) | tuple(self._items) or list(self._items) |
Two cautions apply to the read-only options. A C++ const reference or a Java view stays connected to the object, so it changes when the object changes, and a C++ reference must not be used after its object is destroyed. And “read-only” covers the container, not its elements: if the elements are mutable objects, callers can still change them through the view. The same leak works in the other direction, too. A constructor that stores a caller’s list without copying it shares that list with the caller, who can change it later; copying mutable arguments on the way in closes that gap.
Getters and Setters Are Not Encapsulation
A class with private fields and a public getter and setter for each one is encapsulated in form only. Compare two ways of taking 30 out of an account:
acct.setBalance(acct.getBalance() - 30)reads the data, makes the decision outside the class, and writes the result back. The rule that a balance may not go negative now has to be repeated by every caller, and any caller can forget it.acct.withdraw(30)asks the object to do the job. The rule lives in one method, and the account above has nosetBalanceat all.
The second style is often summed up as “tell, don’t ask”: tell an object what to do instead of asking for its data and acting on it yourself. A useful test when designing a class is to write the operations callers need (withdraw, add, rename) before writing any accessor, and to add a getter only when a caller genuinely needs the value. Many classes need fewer setters than their fields suggest; the account needs none.
Not every class needs encapsulation. A type with no rules to protect, such as a point with an x and a y that may take any values, gains nothing from private fields and accessor pairs. C++ uses a struct with public members for this, Java has record classes (since Java 16), and Python has @dataclass. Records and dataclasses can still validate their values when they are constructed, and a record’s fields can’t be reassigned afterwards.
Encapsulation in Python: Properties and Conventions
Python has no private keyword. Its documentation says plainly that “private” instance variables “don’t exist in Python”, and that a leading underscore marks a name as a non-public part of the API, an implementation detail that may change without notice (Python tutorial: private variables). Encapsulation in Python is therefore a contract between the class and its callers rather than a rule the interpreter enforces, and the language provides properties to make the public side of that contract look like plain attributes:
"""properties.py - a property adds validation behind an attribute name,
so callers keep writing c.radius. The underscore is only a convention."""
import math
class Circle:
def __init__(self, radius: float) -> None:
self.radius = radius # goes through the setter below
@property
def radius(self) -> float:
return self._radius
@radius.setter
def radius(self, value: float) -> None:
if value < 0:
raise ValueError(f"radius must be non-negative: {value}")
self._radius = value
@property
def area(self) -> float:
return math.pi * self._radius ** 2
c = Circle(2.0)
print(f"radius {c.radius}, area {c.area:.2f}")
try:
c.radius = -1
except ValueError as e:
print(f"error: {e}")
print(f"radius still {c.radius}")
c._radius = -1 # nothing stops this: Python trusts the caller to respect the underscore
print(f"after c._radius = -1: radius {c.radius}")
Output:
radius 2.0, area 12.57
error: radius must be non-negative: -1
radius still 2.0
after c._radius = -1: radius -1
Callers write c.radius = -1, exactly as if radius were an ordinary attribute, and the setter rejects it. This is why Python code usually starts with public attributes and no getters or setters: if validation becomes necessary later, a property can be added without changing any caller. The area property shows the other use, a computed value that callers read like a field.
The last line shows the limit. Writing c._radius directly skipped the setter and left the circle with a negative radius. A double underscore (__radius) would rename the attribute to _Circle__radius, but that exists to prevent name clashes in subclasses, and the tutorial notes it is “still possible to access or modify a variable that is considered private”. In Python, the underscore is the boundary, and respecting it is the caller’s responsibility. Linters such as Pylint flag access to protected members from outside a class, which helps a team enforce the convention.
Encapsulation vs Abstraction vs Data Hiding
The three terms are often used interchangeably. They describe different things:
| Aspect | Encapsulation | Abstraction | Data hiding |
|---|---|---|---|
| Question it answers | Who may change this data, and how? | What does this type do, ignoring how? | Which members can outside code see? |
| Main tool | A class that owns its data and checks every change | Interfaces, abstract classes, well-named methods | Access modifiers (private, protected), or conventions |
| In the account example | withdraw is the only way to reduce the balance | Callers think of “an account”, not a long and a vector | balance_ is private |
| What it protects | The object’s invariants | Callers from implementation details | The fields from direct access |
The three work together. Abstraction decides what the public interface should be, encapsulation keeps the data behind that interface valid, and data hiding is how a language enforces the boundary between them.
Key Takeaways
- Encapsulation keeps data and the code that changes it together, and lets outside code change the data only through methods that check it. The account’s balance stayed at 120 after an overdraft and a negative amount were both rejected.
- Private fields are the tool, not the goal. The goal is that the class’s rules (its invariants) always hold.
- A getter can leak private data. Returning the internal list let callers take a three-member team to five; a
constreference, an unmodifiable view or a copy prevented it. - Getters and setters do not add encapsulation by themselves. Methods such as
withdrawthat enforce rules do. - Python encapsulates by convention. Properties validate changes without changing the caller’s syntax, and a leading underscore marks internals, but nothing prevents access to them.
Conclusion
Encapsulation turns a class’s rules from advice into guarantees. When the data can change only through methods that check it, an invalid object can’t be created by accident, and when one does turn up, the code that caused it is confined to a single class. C++ and Java enforce the boundary at compile time; Python leaves it to convention and gives properties for the cases that need checks. In all three, the useful question is the same: what must always be true about this object, and which methods are allowed to change it?
For the C++-specific detail, including friend declarations, a class that owns a resource, and whether private costs anything at run time, see encapsulation in C++. The rest of the object-oriented series builds on the same ideas: objects covers identity and copying, which explains why returning a reference shares data, and inheritance defines new classes from existing ones, where protected members decide how far the boundary opens. More topics are collected in the OOP section.
Source Code and Tests
All programs on this page are in the oop/encapsulation-concepts directory of the MYCPLUS C++ examples repository.
Build and test:
bash tests/run_tests.sh g++
What the build checks. On Linux, with GCC and with Clang, it compiles the C++ programs with -Wall -Wextra -pedantic -Werror, compiles the Java programs with javac -Xlint:all -Werror on Java 21, runs the Python programs with -W error on Python 3.12, and checks that every program prints exactly the output shown on this page, with a separate expected output per language for the leaking-getter example. Python 3.11 and 3.13 were checked locally and produced the same output. The compile errors and the AttributeError quoted in the table were produced by temporarily adding the assignments; they are not part of the automated build.




