Encapsulation in Object-Oriented Programming: Private State and Invariants in C++, Java and Python

How private state and checking methods keep an object valid, why a getter can leak it, and how Python gets encapsulation from properties and convention.

A blue vending machine with items behind glass and a column of buttons, representing an object whose contents are reached only through its public interface.

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:

PartWhat it meansIn the account example
BundleData and the operations on it live in one classbalance and history sit next to deposit and withdraw
RestrictOutside code cannot reach the data directlyThe fields are private (C++, Java) or marked private by convention (Python)
EnforceEvery change goes through a method that checks the ruleswithdraw 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:

LanguageStatementResult
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
Pythonacct.balance = 1000000AttributeError: property 'balance' of 'Account' object has no setter
Pythonacct._balance = 1000000Runs. 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.

Encapsulation: the only way in is through the methods The Account from account.cpp, Account.java and account.py, after its three transactions Outside code acct.deposit(50) acct.withdraw(500) error: insufficient funds acct.balance() (acct.balance in Python) acct.balance = 1000000 rejected Account object deposit(amount) withdraw(amount) balance() history() public: callable from anywhere private state balance 120 history +100 +50 -30 rule: balance >= 0 reachable only by the Account’s own code How each language rejects the direct write: C++ (acct.balance_ = …) and Java: a compile error, because the field is private. Python: AttributeError, because the balance property has no setter.
Private state changes only through the methods that guard it. Calls such as withdraw(500) reach the balance only after a check, so the account keeps its rule; a direct write is refused by the compiler in C++ and Java and by the missing property setter in Python.

Access Control Compared

Each language gives a different amount of help in restricting access:

AspectC++JavaPython
Private to the classprivate:private_name by convention; __name is renamed to _Class__name
Visible to subclassesprotected:protected (also visible in the same package)No language support; _name is the convention
Default for membersprivate in a class, public in a structPackage-private: visible within the same packagePublic
Checked whenCompile timeCompile timeNot checked; read-only properties raise errors at run time
Exceptionsfriend functions and classesReflection, subject to the module systemEverything 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 const reference. Calling push_back on 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 throws UnsupportedOperationException (the unmodifiableList entry in the Java Collections documentation).
  • Python returns a tuple, a copy that has no append method, so the attempt raises AttributeError.
NeedC++JavaPython
Read-only access to the live dataReturn 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 snapshotReturn std::vector<T> by valueList.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 no setBalance at 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:

AspectEncapsulationAbstractionData hiding
Question it answersWho may change this data, and how?What does this type do, ignoring how?Which members can outside code see?
Main toolA class that owns its data and checks every changeInterfaces, abstract classes, well-named methodsAccess modifiers (private, protected), or conventions
In the account examplewithdraw is the only way to reduce the balanceCallers think of “an account”, not a long and a vectorbalance_ is private
What it protectsThe object’s invariantsCallers from implementation detailsThe 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 const reference, an unmodifiable view or a copy prevented it.
  • Getters and setters do not add encapsulation by themselves. Methods such as withdraw that 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

encapsulation-concepts

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.

Scroll to Top