Encapsulation in OOP: Access Modifiers and Data Hiding

Encapsulation in OOP: data hiding, access modifiers compared across C++, Java and Python, getters and setters with validation, and immutable classes.

What is encapsulation in OOP?

Encapsulation is bundling an object's data together with the methods that operate on it, and restricting direct access to that data so it can change only through those methods. It lets a class enforce its own rules — a balance that never goes negative, a mark between 0 and 100 — and change its internals without breaking the code that uses it.

Encapsulation is the first pillar of OOP and the one that does the most everyday work. A class bundles its data with the methods that use that data, and it hides the data so that nothing outside the class can change it except through those methods. The payoff is that the class can promise things about its own state — "the stock is never negative", "the email always contains an @" — and keep the promise, because every change goes through code it controls. This note covers how C++, Java and Python each restrict access, how to write setters that actually protect something, and how to build immutable classes.

What encapsulation means

Encapsulation has two parts:

  1. Bundling — the data and the operations on it live in one class, so the rules about the data live next to it.
  2. Access restriction (data hiding) — fields are made inaccessible from outside, and the class offers methods as the only way in.

The rules a class keeps true about its own state are called invariants. A Product might have two: price is positive, stock is not negative. If stock were a public field, any line anywhere could set it to −5 and the invariant would be gone. With the field private, only the class's methods can change it, and each method checks the rule.

Encapsulation pays off in three ways:

  • Correctness — invariants are checked in one place instead of at every call site.
  • Freedom to change — callers depend on methods, not fields, so you can store a price in paise instead of rupees, or cache a computed value, without touching any caller.
  • Easier debugging — when a value goes wrong, there are only a few methods that could have changed it.

Access modifiers in C++, Java and Python

C++

C++ has three access specifiers, written as labels inside the class:

  • public — anyone can use the member.
  • protected — the class itself, classes derived from it, and its friends.
  • private — the class itself and its friends only.

Members of a class are private until a label says otherwise; members of a struct are public. A class can grant access to a named function or class with friend — the class decides who its friends are, so this does not let outside code break in uninvited.

Java

Java has four levels, one of them without a keyword:

ModifierSame classSame packageSubclass in another packageAnywhere
privateYesNoNoNo
none (package-private)YesYesNoNo
protectedYesYesYes, through inheritanceNo
publicYesYesYesYes
package shoppackage appProductpublic int stock;void sell(int qty)Inventorycan use stockSalePen extends Productcan use its own stockMaincan use stockpublic int stock;
Who can use a field, for each of Java's four access levels. Example: class Product in package shop; SalePen and Main in package app
  1. private: only Product's own code, such as sell(), may touch stock. Inventory, the subclass and Main all fail to compile, which is what lets Product keep its invariant.
  2. With no modifier, Java opens the member to every class in package shop, so Inventory compiles too. Code in another package, even a subclass, still cannot see it.
  3. protected adds subclasses in other packages, through their own type: SalePen can use the stock it inherits. Java's protected also keeps the whole package, which C++'s does not.
  4. public removes the wall: all 4 kinds of code can read and write stock, including Main, and Product can no longer promise anything about its value.

One detail trips people up: a subclass in another package can use an inherited protected member only through references of its own type, not through an arbitrary parent-class object. A top-level class can only be public or package-private.

Python

Python has no access keywords. It relies on naming conventions:

  • name — public.
  • _name — internal; please do not use it from outside. Nothing stops you.
  • __name (two leading underscores, at most one trailing) — name mangling: inside class Account, self.__pin is stored as self._Account__pin. The goal is to stop a subclass accidentally overwriting the attribute, not security; obj._Account__pin still works.

Python's tool for controlled access is @property, which makes a method look like an attribute. Callers write p.price = 25, and the property's setter runs and can reject the value.

Side by side

LevelC++JavaPython
Everyonepublicpublicname
Class and subclassesprotectedprotected (plus the package)_name, by convention only
Same package or moduleno equivalentpackage-private (no keyword)_name at module level, by convention
Class onlyprivateprivate__name, name-mangled but reachable
Default when unspecifiedprivate in a class, public in a structpackage-privatepublic
Who enforces itThe compilerThe compiler and the JVMNobody: convention

In C++ and Java, access is checked per class, not per object: a method of Account may read the private balance of another Account object. That is what makes equals(Account other) and copy constructors possible.

Getters and setters with validation

A getter returns a field's value; a setter changes it. A setter earns its place only if it checks something. Better still, give the class methods that describe real operations — sell(quantity) rather than setStock(getStock() - quantity) — a guideline often called tell, don't ask. The class below rejects invalid prices and stock levels, and sell refuses to oversell:

#include <iostream>
#include <stdexcept>
#include <string>
using namespace std;

class Product {
    string name;
    int price;  // rupees; invariant: > 0
    int stock;  // invariant: >= 0
public:
    Product(string name, int price, int stock) : name(name), price(0), stock(0) {
        setPrice(price);  // the constructor uses the same checks
        setStock(stock);
    }
    int getPrice() const { return price; }
    void setPrice(int p) {
        if (p <= 0) throw invalid_argument("price must be positive");
        price = p;
    }
    void setStock(int s) {
        if (s < 0) throw invalid_argument("stock cannot be negative");
        stock = s;
    }
    void sell(int qty) {  // an operation, not a raw setter
        if (qty > stock) throw invalid_argument("only " + to_string(stock) + " in stock");
        stock -= qty;
    }
    void show() const { cout << name << ": Rs " << price << ", stock " << stock << "\n"; }
};

int main() {
    Product pen("Pen", 20, 100);
    pen.show();
    try { pen.setPrice(-5); } catch (const invalid_argument& e) { cout << "rejected: " << e.what() << "\n"; }
    try { pen.setStock(-1); } catch (const invalid_argument& e) { cout << "rejected: " << e.what() << "\n"; }
    pen.sell(30);
    try { pen.sell(500); } catch (const invalid_argument& e) { cout << "rejected: " << e.what() << "\n"; }
    pen.setPrice(25);
    pen.show();
    return 0;
}
class Product {
    private final String name;
    private int price;  // rupees; invariant: > 0
    private int stock;  // invariant: >= 0

    Product(String name, int price, int stock) {
        this.name = name;
        setPrice(price);  // the constructor uses the same checks
        setStock(stock);
    }

    int getPrice() { return price; }

    void setPrice(int p) {
        if (p <= 0) throw new IllegalArgumentException("price must be positive");
        price = p;
    }

    void setStock(int s) {
        if (s < 0) throw new IllegalArgumentException("stock cannot be negative");
        stock = s;
    }

    void sell(int qty) {  // an operation, not a raw setter
        if (qty > stock) throw new IllegalArgumentException("only " + stock + " in stock");
        stock -= qty;
    }

    void show() { System.out.println(name + ": Rs " + price + ", stock " + stock); }
}

public class Main {
    public static void main(String[] args) {
        Product pen = new Product("Pen", 20, 100);
        pen.show();
        try { pen.setPrice(-5); } catch (IllegalArgumentException e) { System.out.println("rejected: " + e.getMessage()); }
        try { pen.setStock(-1); } catch (IllegalArgumentException e) { System.out.println("rejected: " + e.getMessage()); }
        pen.sell(30);
        try { pen.sell(500); } catch (IllegalArgumentException e) { System.out.println("rejected: " + e.getMessage()); }
        pen.setPrice(25);
        pen.show();
    }
}
class Product:
    def __init__(self, name, price, stock):
        self._name = name
        self.price = price  # goes through the property setter below
        self.stock = stock

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, p):
        if p <= 0:
            raise ValueError("price must be positive")
        self._price = p

    @property
    def stock(self):
        return self._stock

    @stock.setter
    def stock(self, s):
        if s < 0:
            raise ValueError("stock cannot be negative")
        self._stock = s

    def sell(self, qty):  # an operation, not a raw setter
        if qty > self._stock:
            raise ValueError(f"only {self._stock} in stock")
        self._stock -= qty

    def show(self):
        print(f"{self._name}: Rs {self._price}, stock {self._stock}")


pen = Product("Pen", 20, 100)
pen.show()
try:
    pen.price = -5
except ValueError as e:
    print(f"rejected: {e}")
try:
    pen.stock = -1
except ValueError as e:
    print(f"rejected: {e}")
pen.sell(30)
try:
    pen.sell(500)
except ValueError as e:
    print(f"rejected: {e}")
pen.price = 25
pen.show()
class Product {
  #name; #price; #stock;

  constructor(name, price, stock) {
    this.#name = name;
    this.price = price; // goes through the setter below
    this.stock = stock;
  }

  get price() { return this.#price; }
  set price(p) {
    if (p <= 0) throw new RangeError("price must be positive");
    this.#price = p;
  }

  get stock() { return this.#stock; }
  set stock(s) {
    if (s < 0) throw new RangeError("stock cannot be negative");
    this.#stock = s;
  }

  sell(qty) { // an operation, not a raw setter
    if (qty > this.#stock) throw new RangeError(`only ${this.#stock} in stock`);
    this.#stock -= qty;
  }

  show() { console.log(`${this.#name}: Rs ${this.#price}, stock ${this.#stock}`); }
}

const pen = new Product("Pen", 20, 100);
pen.show();
try { pen.price = -5; } catch (e) { console.log(`rejected: ${e.message}`); }
try { pen.stock = -1; } catch (e) { console.log(`rejected: ${e.message}`); }
pen.sell(30);
try { pen.sell(500); } catch (e) { console.log(`rejected: ${e.message}`); }
pen.price = 25;
pen.show();
Pen: Rs 20, stock 100
rejected: price must be positive
rejected: stock cannot be negative
rejected: only 70 in stock
Pen: Rs 25, stock 70
pen : Product — fields private, methods publicsetPrice()setStock()sell()show()name = "Pen"price = 25stock = 70pen.setPrice(25); pen.show();> Pen: Rs 20, stock 100> rejected: price must be positive> rejected: stock cannot be negative> rejected: only 70 in stock> Pen: Rs 25, stock 70
Every change to pen goes through a method that checks it. Example: pen = Product("Pen", 20, 100)
  1. The constructor runs the same setPrice and setStock checks as everyone else, so pen starts valid: price 20, stock 100. An invalid Product can never be created.
  2. Outside code cannot reach a private field at all: this line does not compile. The public methods on the object's edge are the only way in, and each one checks before it writes.
  3. setPrice(-5) fails its check and throws before the assignment, so price is still 20. A failed call leaves the object exactly as it was.
  4. setStock(-1) is refused the same way: stock stays 100, because stock may never be negative.
  5. sell(30) is an operation, not a raw setter: it checks there is enough stock, then writes 100 − 30 = 70 itself.
  6. sell(500) asks for more than the 70 in stock, so sell refuses and says why. The caller never had to check; the rule lives in one place.
  7. A valid price goes through: price becomes 25. Of the six calls, three were refused, and stock and price kept their rules after every one.

Immutable classes

An immutable object cannot change after it is built. Immutable objects are simple to reason about, safe to share between threads without locks, and safe to use as keys in hash maps. Java's String, Integer and LocalDate, and Python's str, int, tuple and frozenset, are all immutable.

The recipe, in Java terms:

  1. Make every field private final and set each one in the constructor.
  2. Provide no setters. Methods that "change" the object return a new object instead (plus, withPrice).
  3. Make the class final, so no subclass can add mutable state or override methods to behave mutably.
  4. Make defensive copies of mutable inputs (a list passed to the constructor) and never hand out a mutable internal object.

Java 16's record covers rules 1–3 in one line, though a record holding a List is only as immutable as that list. In Python, @dataclass(frozen=True) makes assignment to a field raise an error, and storing a tuple instead of a list covers rule 4. In C++, const member functions promise not to change the object, const objects can call only those, and value members are copied on construction automatically.

#include <iostream>
#include <iomanip>
#include <string>
#include <vector>
using namespace std;

class Money {  // immutable: private state, no setters, const methods only
    const long paise;
    const string currency;
public:
    Money(long paise, string currency) : paise(paise), currency(currency) {}
    Money plus(const Money& other) const { return Money(paise + other.paise, currency); }
    void show(const string& label) const {
        cout << label << ": " << currency << " " << paise / 100 << "."
             << setw(2) << setfill('0') << paise % 100 << "\n";
    }
};

class Roster {
    const vector<string> names;  // stored by value: the caller's vector is copied
public:
    explicit Roster(const vector<string>& names) : names(names) {}
    size_t size() const { return names.size(); }
};

int main() {
    Money price(49950, "INR");
    Money total = price.plus(Money(5025, "INR"));  // a new object; price is untouched
    price.show("price");
    total.show("total");

    vector<string> input = {"Asha", "Ravi"};
    Roster roster(input);
    input.push_back("Kiran");  // changing the caller's vector
    cout << "roster size: " << roster.size() << "\n";
    return 0;
}
import java.util.ArrayList;
import java.util.List;

final class Money {  // final: no subclass can add mutable state
    private final long paise;
    private final String currency;

    Money(long paise, String currency) { this.paise = paise; this.currency = currency; }

    Money plus(Money other) { return new Money(paise + other.paise, currency); }

    void show(String label) {
        System.out.println(label + ": " + currency + " " + paise / 100 + "." + String.format("%02d", paise % 100));
    }
}

final class Roster {
    private final List<String> names;
    Roster(List<String> names) { this.names = List.copyOf(names); }  // defensive, unmodifiable copy
    int size() { return names.size(); }
}

public class Main {
    public static void main(String[] args) {
        Money price = new Money(49950, "INR");
        Money total = price.plus(new Money(5025, "INR"));  // a new object; price is untouched
        price.show("price");
        total.show("total");

        List<String> input = new ArrayList<>(List.of("Asha", "Ravi"));
        Roster roster = new Roster(input);
        input.add("Kiran");  // changing the caller's list
        System.out.println("roster size: " + roster.size());
    }
}
from dataclasses import dataclass


@dataclass(frozen=True)  # assigning to a field raises FrozenInstanceError
class Money:
    paise: int
    currency: str

    def plus(self, other):
        return Money(self.paise + other.paise, self.currency)

    def show(self, label):
        print(f"{label}: {self.currency} {self.paise // 100}.{self.paise % 100:02d}")


class Roster:
    def __init__(self, names):
        self._names = tuple(names)  # defensive, immutable copy

    def size(self):
        return len(self._names)


price = Money(49950, "INR")
total = price.plus(Money(5025, "INR"))  # a new object; price is untouched
price.show("price")
total.show("total")

names = ["Asha", "Ravi"]
roster = Roster(names)
names.append("Kiran")  # changing the caller's list
print(f"roster size: {roster.size()}")
price: INR 499.50
total: INR 549.75
roster size: 2
variablesobjects (never changed)Moneypaise = 49950currency = "INR"Money (the argument)paise = 5025currency = "INR"Moneypaise = 54975currency = "INR"pricetotalMoney total = price.plus(new Money(5025, "INR"));
An immutable object is never changed; a change makes a new one. Example: price = Money(49950, "INR"); total = price.plus(Money(5025, "INR"))
  1. price refers to a Money of 49950 paise, INR 499.50. Its fields are private and final, and Money has no setters, so this object will hold that amount for as long as it exists.
  2. plus does not add into price. It builds a third object holding 49950 + 5025 = 54975 paise, and price still reads INR 499.50 while total reads INR 549.75.
referenceslistscaller's list"Asha""Ravi""Kiran"inputroster.namesthis.names = names; // no copyroster.size() → 3
Why an immutable class copies the list it is given. Example: input = ["Asha", "Ravi"]; roster = Roster(input); input.add("Kiran")
  1. The constructor copies the caller's list, so the roster's field refers to a list nobody else holds. Two references, two lists.
  2. The caller adds Kiran to its own list, which now holds 3 names. The roster's copy is untouched and size() still says 2, as the note's program prints.
  3. Without the copy, the field is one more reference to the caller's list, and the same add changes the "immutable" roster behind its back: size() says 3.

A missing defensive copy is the most common way an "immutable" class turns out not to be.

Encapsulation vs abstraction vs data hiding

EncapsulationData hidingAbstraction
IdeaBundle data with its methodsRestrict direct access to dataShow what, hide how
HidesThe internal stateThe internal stateImplementation complexity
ToolClassesAccess modifiersInterfaces, abstract classes
LevelImplementationImplementationDesign
ExampleProduct with sell()private int stockA PaymentMethod interface

Abstraction gets its own note: Abstraction: Abstract Classes and Interfaces.

Common mistakes

  • Generating a getter and setter for every private field; a setter with no checks is a public field with extra steps.
  • Returning an internal mutable list from a getter, so callers can modify it behind the class's back.
  • Believing Python's __name makes an attribute private; it only renames it.
  • Assuming Java's protected means "subclasses only"; it also opens the member to the whole package.
  • Validating in setters but not in the constructor, so invalid objects can still be created.
  • Marking fields final and calling the class immutable while a field holds a mutable list.

Interview questions

How do you achieve encapsulation in Java? Declare the fields private, expose behaviour through public methods that validate their input, and keep helper classes package-private. Packages, and since Java 9 modules, extend the same idea to whole groups of classes by choosing which ones other code can see.

Can a method read the private fields of another object of the same class? Yes, in both C++ and Java access control is per class, not per object. A method of Account may read other.balance when other is also an Account. This is what lets you write copy constructors, equals methods and comparison operators.

What is name mangling in Python, and does it make an attribute private? An attribute written __pin inside class Account is stored as _Account__pin. It exists to prevent accidental clashes when a subclass uses the same name. It is not privacy: obj._Account__pin still reads it.

How does protected differ between C++ and Java? In C++, a protected member is visible to the class, its derived classes and friends. In Java it is also visible to every class in the same package, whether or not it is a subclass. Java subclasses in other packages may use it only through their own type.

Why should an immutable class be final in Java? A subclass could add mutable fields or override methods so that its objects appear to change, and code holding a reference of the parent type would be fooled. Making the class final, or making the constructor private and offering a static factory, closes that hole.

Is a C++ friend function a violation of encapsulation? Not necessarily. The class itself names its friends, so access is still granted deliberately and in one place. A friend is best seen as part of the class's interface — operator<< for printing is the common example. Granting friendship widely does weaken the class's guarantees.

Can Java reflection access private fields? Yes: Field.setAccessible(true) lets code read and write private fields of ordinary classes. Since Java 9 the module system can block this for packages a module does not open, and the JDK's own internals have been blocked by default since Java 16. Access modifiers guard against mistakes, not against hostile code in the same process.

Are getters and setters always good practice? No. They are better than public fields because they leave room for validation and change, but a class made of nothing else is a data holder whose rules live elsewhere. Prefer methods that perform meaningful operations and keep the rules inside the class.

Next, read Inheritance, then check yourself with the OOP Basic skill test.

Common questions

What is the difference between encapsulation and data hiding?

Data hiding is the access-restriction part: making fields private so outside code cannot touch them. Encapsulation is the wider idea of bundling data with the methods that use it, with data hiding as the tool that makes the bundle trustworthy. Many textbooks use the two words almost interchangeably.

What is the difference between encapsulation and abstraction?

Encapsulation is about protecting an object's data by bundling it with methods and hiding it behind access control. Abstraction is about exposing only what an object does and hiding how it does it, usually through interfaces or abstract classes. Encapsulation hides data; abstraction hides complexity.

Does Python support private variables?

Not in the enforced sense. A single leading underscore (_balance) marks an attribute as internal by convention, and a double leading underscore (__balance) triggers name mangling to _ClassName__balance, which avoids clashes in subclasses. Any code can still reach either one; Python relies on programmers respecting the convention.

What is the default access modifier in Java?

When you write no modifier, a member is package-private: visible to every class in the same package and to nothing outside it, not even subclasses in other packages. There is no keyword for it, so it is often called default access. Interface members are the exception: they are public by default.

Why do we use getters and setters?

They let a class control how a field is read and changed: a setter can validate the new value, a getter can compute or copy what it returns, and the field's storage can change later without changing callers. A setter that accepts anything, though, protects nothing; prefer methods that express real operations.

What is an immutable class?

An immutable class is one whose objects cannot change after construction: all fields are set in the constructor, none has a setter, and any method that seems to modify the object returns a new one instead. Java's String and Integer and Python's str and tuple are immutable.

Test yourself

← Classes and Objects · Inheritance →