Skip to main content

Encapsulation — Data Hiding Done Right

Editorial

Introduction

In the previous two tutorials, we learned how to define classes, create objects, and initialize them with constructors. We even added validation inside constructors to reject bad data at creation time.

But here's a problem: what happens after the object is created?

Consider our BankAccount class. The constructor validates that the balance isn't negative. But nothing stops someone from writing account.balance = -5000 afterward, completely bypassing that validation.

account = BankAccount("Alice", 1000.0)   # Constructor says: valid ✓
account.balance = -5000                  # Direct access says: sure, whatever!

This is like a bank that checks your ID at the door but then leaves the vault wide open. Encapsulation solves this. It's the practice of:

  1. Hiding internal data — making attributes private so they can't be accessed directly
  2. Providing controlled access — exposing methods (getters/setters) that validate every change

Encapsulation is not just a theoretical concept. It's the most practical tool you have for writing code that doesn't break when teammates modify it — a concern you'll face in every real-world project.

The Problem Without Encapsulation

Let's see what goes wrong when data is left exposed:

# BAD: No encapsulation — everything is public
class BankAccountUnsafe:
    def __init__(self, owner: str, balance: float):
        self.owner = owner
        self.balance = balance

    def withdraw(self, amount: float) -> None:
        if amount > self.balance:
            print("Insufficient funds!")
            return
        self.balance -= amount


account = BankAccountUnsafe("Alice", 1000.0)

# Anyone can bypass the withdraw method and break the rules:
account.balance = -99999      # No validation!
account.owner = ""             # Empty owner? Sure!
account.balance = "not money"  # A string? Why not!

print(account.balance)  # 'not money' — disaster

# The withdraw method has validation, but it's useless
# because anyone can just modify balance directly.

The problem is clear: public attributes can be changed to anything, at any time, by anyone, bypassing all business rules. Encapsulation is the fix.

Encapsulation. Left (exposed, public attributes): account.balance = -5000, account.owner = "", account.balance = "not money" all apply and corrupt the object - validation bypassed. Right (encapsulated vault): __balance is private/locked; changes go through validated doors deposit()/withdraw(); balance is read-only; account.balance = -5000 is blocked (no setter). Access modifiers: public (self.name / java public - anyone), protected (self._name / protected - class + subclasses), private (self.__name / private - this class only).

Access Modifiers — Controlling Visibility

Both Python and Java offer ways to control who can access an attribute or method. Here's the comparison:

ModifierPython ConventionJava KeywordWho Can Access
Publicself.name (no prefix)publicAnyone, from anywhere
Protectedself._name (single _)protectedClass itself + subclasses
Privateself.__name (double __)privateClass itself only

Key difference: Java enforces access modifiers at the compiler level — accessing a private field from outside the class is a compile error. Python uses naming conventions — the double underscore triggers name mangling (the attribute is renamed internally), but determined code can still access it. Python trusts developers to follow conventions.

The principle is the same in both languages: mark internal data as private, and expose only what's necessary.

Encapsulation in Practice — The Safe Bank Account

Let's rebuild BankAccount with proper encapsulation. The core idea: all attributes are private, and every change goes through a method that validates the operation.

class BankAccount:
    """A properly encapsulated bank account.
    
    All attributes are private (double underscore).
    All access goes through methods with validation.
    """

    def __init__(self, owner: str, balance: float = 0.0):
        if not owner.strip():
            raise ValueError("Owner name cannot be empty")
        if balance < 0:
            raise ValueError("Initial balance cannot be negative")

        self.__owner = owner.strip()     # Private!
        self.__balance = balance          # Private!
        self.__is_frozen = False          # Private!

    # --- Getters: read-only access to private data ---

    @property
    def owner(self) -> str:
        """Read-only access to owner name."""
        return self.__owner

    @property
    def balance(self) -> float:
        """Read-only access to balance."""
        return self.__balance

    @property
    def is_frozen(self) -> bool:
        """Read-only access to frozen status."""
        return self.__is_frozen

    # --- Controlled operations: the ONLY way to change state ---

    def deposit(self, amount: float) -> None:
        """Add money to the account with validation."""
        if self.__is_frozen:
            raise RuntimeError("Cannot deposit into a frozen account")
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")
        self.__balance += amount
        print(f"Deposited ${amount:.2f}. New balance: ${self.__balance:.2f}")

    def withdraw(self, amount: float) -> None:
        """Remove money from the account with validation."""
        if self.__is_frozen:
            raise RuntimeError("Cannot withdraw from a frozen account")
        if amount <= 0:
            raise ValueError("Withdrawal amount must be positive")
        if amount > self.__balance:
            raise ValueError(
                f"Insufficient funds: ${amount:.2f} > ${self.__balance:.2f}"
            )
        self.__balance -= amount
        print(f"Withdrew ${amount:.2f}. New balance: ${self.__balance:.2f}")

    def freeze(self) -> None:
        """Freeze the account — no deposits or withdrawals allowed."""
        self.__is_frozen = True
        print(f"Account for {self.__owner} is now FROZEN.")


# --- Usage ---
account = BankAccount("Alice", 1000.0)

# Read data through properties (getters)
print(f"Owner: {account.owner}")      # Alice
print(f"Balance: ${account.balance}")  # $1000.0

# Modify through controlled methods
account.deposit(500.0)    # Deposited $500.00. New balance: $1500.00
account.withdraw(200.0)   # Withdrew $200.00. New balance: $1300.00

# Try to break the rules — all blocked:
try:
    account.balance = -5000  # AttributeError: property has no setter
except AttributeError as e:
    print(f"Blocked: {e}")

try:
    account.withdraw(-100)   # ValueError: must be positive
except ValueError as e:
    print(f"Blocked: {e}")

try:
    account.withdraw(99999)  # ValueError: insufficient funds
except ValueError as e:
    print(f"Blocked: {e}")

# Freeze the account
account.freeze()
try:
    account.deposit(100)     # RuntimeError: cannot deposit into frozen
except RuntimeError as e:
    print(f"Blocked: {e}")
public class BankAccount {
    // All fields are private — no direct access from outside
    private String owner;
    private double balance;
    private boolean isFrozen;

    public BankAccount(String owner, double balance) {
        if (owner == null || owner.trim().isEmpty()) {
            throw new IllegalArgumentException("Owner name cannot be empty");
        }
        if (balance < 0) {
            throw new IllegalArgumentException("Initial balance cannot be negative");
        }
        this.owner = owner.trim();
        this.balance = balance;
        this.isFrozen = false;
    }

    // --- Getters: read-only access ---
    public String getOwner() {
        return owner;
    }

    public double getBalance() {
        return balance;
    }

    public boolean isFrozen() {
        return isFrozen;
    }

    // --- Controlled operations ---
    public void deposit(double amount) {
        if (isFrozen) {
            throw new RuntimeException("Cannot deposit into a frozen account");
        }
        if (amount <= 0) {
            throw new IllegalArgumentException("Deposit amount must be positive");
        }
        balance += amount;
        System.out.printf("Deposited $%.2f. New balance: $%.2f%n", amount, balance);
    }

    public void withdraw(double amount) {
        if (isFrozen) {
            throw new RuntimeException("Cannot withdraw from a frozen account");
        }
        if (amount <= 0) {
            throw new IllegalArgumentException("Withdrawal amount must be positive");
        }
        if (amount > balance) {
            throw new IllegalArgumentException(
                    String.format("Insufficient funds: $%.2f > $%.2f", amount, balance));
        }
        balance -= amount;
        System.out.printf("Withdrew $%.2f. New balance: $%.2f%n", amount, balance);
    }

    public void freeze() {
        isFrozen = true;
        System.out.println("Account for " + owner + " is now FROZEN.");
    }

    // Note: No setBalance(), no setOwner() — these can ONLY
    // be changed through deposit() and withdraw().

    public static void main(String[] args) {
        BankAccount account = new BankAccount("Alice", 1000.0);

        System.out.println("Owner: " + account.getOwner());
        System.out.printf("Balance: $%.2f%n", account.getBalance());

        account.deposit(500.0);
        account.withdraw(200.0);

        // account.balance = -5000; // COMPILE ERROR: balance is private

        try {
            account.withdraw(-100);
        } catch (IllegalArgumentException e) {
            System.out.println("Blocked: " + e.getMessage());
        }

        account.freeze();
        try {
            account.deposit(100);
        } catch (RuntimeException e) {
            System.out.println("Blocked: " + e.getMessage());
        }
    }
}

Notice the design pattern:

  • No setters for owner or balance — there's no setBalance() method. The balance can only change through deposit() and withdraw(), which enforce business rules.
  • Getters are read-only — you can check the balance, but you can't change it directly.
  • Business logic lives inside the class — the rules about frozen accounts, positive amounts, and insufficient funds are enforced by the object itself, not by the code that uses it.

This is the essence of encapsulation: the object protects its own integrity.

Visualization

Fire the same operation at an exposed (public) account and an encapsulated (private) one. Legit deposit/withdraw succeed on both; raw writes (balance = -5000) and rule-breakers (withdraw(-100)) corrupt the exposed account but bounce off the vault with the right exception. Freeze shows the object enforcing lifecycle rules.

When to Use Getters and Setters

A common mistake is to blindly generate getters and setters for every field — this defeats the purpose of encapsulation entirely. Here's the guideline:

ScenarioGetter?Setter?Why
Identity fields (owner, id)✅ Yes❌ NoThese shouldn't change after creation
Computed values (balance)✅ Yes❌ NoChanged only through business operations
Display info (name, email)✅ Yes✅ With validationAllow updates, but validate
Internal state (isFrozen)✅ Yes❌ NoChanged only through specific methods (freeze())
Implementation detail (cache, internal counter)❌ No❌ NoNobody outside needs to know

The golden rule: if you provide a setter, it must validate. A setter that blindly assigns this.x = x with no checks is just a public field with extra steps.

Common Mistakes

Mistake 1: Getter + Setter for Everything

❌ Wrong: Auto-generating getX() and setX() for every field. This is just public fields with extra boilerplate.

✅ Right: Only expose what external code genuinely needs. Prefer operation methods (deposit, withdraw) over raw setters (setBalance).

Mistake 2: Returning Mutable References

❌ Wrong:

@property
def transactions(self):
    return self.__transactions  # Returns the actual list!

The caller can call .append() or .clear() on the returned list, modifying the private data.

✅ Right: Return a copy:

@property
def transactions(self):
    return list(self.__transactions)  # Returns a defensive copy

Mistake 3: Confusing Encapsulation with Hiding Logic

Encapsulation is not about making everything private and untouchable. It's about providing a clean, controlled interface while keeping implementation details flexible. If your class is so locked down that it's impossible to use, you've gone too far.

Key Takeaways

  • Encapsulation = private data + controlled access methods. The object protects its own integrity.
  • Access modifiers control visibility: Python uses __ (name mangling), Java uses the private keyword (compiler-enforced)
  • Getters provide read-only access. Setters should validate — never blindly assign
  • Don't expose everything. Only create getters/setters for what external code actually needs. Prefer business operation methods over raw setters
  • Return defensive copies of mutable attributes (lists, maps) to prevent external modification
  • Encapsulation works hand-in-hand with constructors: constructors validate at birth, encapsulation validates for the lifetime of the object
  • In the next tutorial, we'll learn abstraction — a complementary concept that hides complexity by showing only what the user of a class needs to see