Encapsulation and access: keep the rule with the state

Keep an object’s invariant behind an intent-based API, then compare Java access rules with Python’s conventions. Objects and access · Object-oriented programming.

A shop app shows a customer's wallet balance as -5. No payment took it below zero, yet some code wrote that number. Many objects have a rule like this: a balance never goes below zero. Encapsulation means keeping an object's data and its rules together, so other code cannot change the data directly. It has to ask for an intent-based operation, one named for what the caller wants done, such as deposit(4), and the object checks the request first. Follow two wallets that both start at 12: one lets any code overwrite the balance, the other accepts only deposits.

The rule that must always hold is the object's invariant: here balance >= 0. What a request must satisfy before the object accepts it is a precondition: a deposit must be a positive whole number. Zero is a valid balance, but zero is not a valid deposit under this contract. A private field can be touched only by code inside its own class; a public operation can be called by any code.

Two wallet APIs, one state rule

Read the steps as text

  1. Inspect the starting objects and their fields.
  2. UnsafeWallet U1 receives setBalance(-5). There is no check, so UnsafeWallet U1's balance changes from 12 to -5, and the rule balance >= 0 is broken.
  3. Wallet W1 receives deposit(-5). The check stops it: a deposit must be positive. Nothing is written; the balance stays 12.
  4. Wallet W1 receives deposit(0). The check stops it: a deposit must be positive. Nothing is written; the balance stays 12.
  5. Wallet W1 receives deposit(4). The check passes, so Wallet W1's balance changes from 12 to 16.
  6. UnsafeWallet U1's balance changed from 12 to -5, breaking the rule. Wallet W1's balance changed from 12 to 16. 2 rejected requests changed nothing. A blind setter broke the rule; checks kept it.

Compare two separate wallets, each starting at 12. UnsafeWallet has a private balance but also a public blind setBalance(value): a setter overwrites a field with whatever it is given, while a getter only reads it. Calling setBalance(-5) replaces 12 with -5, breaking the invariant. Wallet exposes deposit instead: deposit(-5) is rejected and leaves 12; deposit(0) is also rejected and leaves 12; deposit(4) passes the check and adds 4, changing 12 to 16. Replacing a balance and adding a deposit are different operations, deliberately compared as API designs.

The rejection happens before a write; it is not an automatic rollback of a bad write. A private field helps only when the surrounding API preserves the rule. A blind setter offers a path around it, while an intent-based operation checks its request at the point that owns the state.

Java and Python name the boundary differently

Source checks and Python convention

Read the steps as text

  1. Inspect the starting objects and their fields.
  2. Java: code outside Wallet tries wallet.balance. The compiler rejects it, because balance is private.
  3. Java: the same outside code calls wallet.deposit(4). A public method is allowed, and it runs its own check before touching balance.
  4. Wallet JWallet receives deposit(4). The check passes, so Wallet JWallet's balance changes from 12 to 16.
  5. Python: ordinary code can read or assign wallet._balance. The underscore is only a convention, so Python does not stop it, and writing -5 would break the rule.
  6. Wallet PWallet's _balance is assigned directly and changes from 12 to -5. No deposit check ran.
  7. Wallet PWallet receives deposit(0). The check stops it: a deposit must be positive. Nothing is written; the balance stays -5.
  8. Python: vault.__balance from outside fails. Python stored the field as _Vault__balance, so no attribute has that name.
  9. Python: vault._Vault__balance works. Mangling avoids name clashes; it does not keep the field secret.
  10. Wallet JWallet's balance changed from 12 to 16. Wallet PWallet's _balance changed from 12 to -5, breaking the rule. Vault's _Vault__balance did not change. 1 rejected request and 2 failed outside accesses changed nothing.

In Java, a private member is checked by the language’s access rules at compile time. For an unrelated ordinary caller outside the Wallet’s top-level class, a direct wallet.balance access is rejected before it can run; an accessible public deposit call can enter the Wallet method, which may update its own private field. Java also has package and protected access; the deeper section below covers them.

Python does not provide a Java-like private instance field. A single leading underscore marks an implementation detail that callers are asked to respect. A double leading underscore triggers name mangling, which helps avoid accidental clashes with a subclass name: inside class Wallet, __balance is stored as _Wallet__balance, so it remains an ordinary object attribute reachable through that name. An ordinary caller can read or assign wallet._balance directly: the underscore itself does not run the deposit check. The convention can guide an API, but it does not enforce a compile-time barrier.

Here the two snippets preserve the same wallet rule with language-specific mechanisms. Java blocks ordinary direct access to balance; Python asks callers to use deposit while storing the implementation detail in _balance.

final class Wallet {
    private int balance = 0;

    public void deposit(int amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("amount must be positive");
        }
        balance = Math.addExact(balance, amount);
    }

    public int balance() {
        return balance;
    }
}
class Wallet:
    def __init__(self):
        self._balance = 0

    def deposit(self, amount):
        if type(amount) is not int or amount <= 0:
            raise ValueError("amount must be positive")
        self._balance += amount

    @property
    def balance(self):
        return self._balance

These examples define an amount as a positive whole-number unit. Math.addExact makes an overflowing deposit throw instead of wrapping to a negative balance, and the Python check accepts only exact integers.

To design a class this way, first write down the rule that must never break. Then offer one operation for each thing callers really want to do, such as deposit, and check the request inside it. Keep the field itself hidden. If the rule changes later, say a deposit limit, you edit one method instead of every caller.

Transfer the rule to a seat pool

Transfer the rule to a seat pool

Read the steps as text

  1. Inspect the starting objects and their fields.
  2. SeatPool P1 receives reserve(3). The check passes, so SeatPool P1's available seats change from 4 to 1.
  3. SeatPool P1 receives reserve(2). The check stops it: only 1 seat is available. Nothing is written; the available seats stay 1.
  4. SeatPool P1 receives reserve(0). The check stops it: a reservation must be positive. Nothing is written; the available seats stay 1.
  5. SeatPool P1's available seats changed from 4 to 1. 2 rejected requests changed nothing. The check kept the pool valid.

A SeatPool has capacity 8 and starts with 4 seats available. Its invariant is 0 <= available <= capacity. Its reserve(count) operation accepts a positive integer no greater than the available seats. Starting at 4, reserve(3) passes and writes 1; reserve(2) is rejected because only 1 remains; reserve(0) is rejected because a reservation must be positive. Both rejected requests leave 1 available. The invariant describes valid pool state; the precondition describes which request the operation accepts.

Recap

  • Start with the invariant and expose an operation that can preserve it; a modifier name alone is not a design.
  • Java access checks and Python underscore conventions have different strength and meaning.
  • Expose only the operations callers need, so a rule has one place to change.
Boundaries behind the compact model

The method name also communicates abstraction: callers need the promise “deposit a positive amount,” not the storage detail. A getter is useful for reading, but it does not turn arbitrary writes into valid state transitions.

Overflow and type detail: Java’s checked addition of 1 to balance 2147483647 throws before assignment, so the valid balance stays unchanged. Python integers do not have this fixed-width overflow limit. Python’s exact-integer check also rejects booleans, floats, NaN, and other types before mutation.

When you review an API, ask two questions in order. First, what state must never be invalid? Second, which operations are allowed to move that state from one valid condition to another? Only then choose whether a member should be public, package-visible, protected, or private in Java, or whether a Python name should be public or conventionally non-public. Visibility is a tool in that design, not the design itself.

this in Java denotes the current object in an instance method; it cannot be used in a static context. self is the conventional first parameter in Python, not a keyword with special syntax. Both examples make the receiver visible, but the access and naming rules still belong to their own language.

A package is a named grouping of Java source types; a subclass is a class that extends another class. A member with no modifier has package access, protected has a same-package rule plus a narrower cross-package subclass rule, and public is available when its declaring type is accessible.

These are source-access rules, not four universal labels shared by every language. Same-package code can access a protected member where the declaring type is accessible. Java’s cross-package protected rule has a receiver condition: inside a subclass body, an instance expression must have the accessing subclass type (or a subclass), so a base-typed object is not a free pass. The receiver condition is easier to trust when you see both outcomes. Imagine these two files:

// p1/Base.java
package p1;

public class Base {
    protected int code = 7;
}

// p2/Child.java
package p2;

import p1.Base;

public class Child extends Base {
    int readThroughChild() {
        Child child = this;
        return child.code;              // allowed
    }

    int readThroughBase() {
        Base base = this;
        return base.code;               // compile-time error
    }
}
class Base:
    def __init__(self):
        self._code = 7


def read_through_both_names(value):
    child = value
    base = value
    return child._code, base._code


record = Base()
read_through_both_names(record)

The Java block is intentionally shown as two package-scoped files inside one tab. Child is in a different package from Base, so the receiver type matters. The first method reads the inherited member through a Child-typed receiver. The second tries to read it through a Base-typed receiver and is rejected by Java's source access check. The object is the same in both methods; the qualifying expression's type is what changes the result.

Python has no equivalent compiler-enforced protected receiver boundary. A leading underscore communicates non-public intent, but it remains an ordinary attribute read when code can reach the object. The Python function gives the same object two local names; both reads are allowed by the language, with no Java-style compiler rejection for the base name. Keep the invariant/API discussion separate: the underscore guides callers, while the deposit operation remains the boundary that preserves wallet state.

At the runtime boundary, Java nestmates may access one another’s private members, and permitted reflection can suppress ordinary language access checks. Those details qualify the source-level model; they do not make access modifiers a secrecy promise. Design the API around the invariant you must preserve.

Next, follow call arguments and rebinding across a method boundary. Later, explore shallow and deep copy to choose which links an edit should isolate. Practise invariant-preserving operations in Stack Fundamentals, where push and pop must keep the structure valid.