Encapsulation
Count the edit sites a representation change costs, move the operation onto the object that owns the data, and read Java and Python access rules honestly.
A class is encapsulated when you can change how it stores its data without editing a single line outside it. That is the whole test, and it is a test you can run: pick a field, imagine changing its type, and count the files you would have to touch. One file means encapsulated. Four means the representation leaked into four places that had no business knowing it.
The test is a count, not a keyword
Take a shopping cart that stores its lines as a list of tuples and exposes the list. Three parts of the application sum it:
# cart.py
class Cart:
def __init__(self):
self.lines = [] # [(sku, qty, unit_cents)]
# checkout.py, invoice.py, refunds.py — three separate files
total = sum(qty * unit for _, qty, unit in cart.lines)
Now a product decision arrives: two scans of the same SKU should merge into one line rather than appear twice. The natural representation is a dict keyed by SKU, which turns the merge from a scan into a single lookup — the same trade described in what a hash map buys you.
The change costs four edits: Cart itself, plus checkout, invoice and refunds,
because each one destructures a tuple that no longer exists. Every new caller
added between now and then adds one more. If the codebase grows by one caller a
month, the same change costs 4 edits today and 16 next year.
Move the operation onto the cart and the count drops to one:
class Cart:
def __init__(self):
self._lines: dict[str, tuple[int, int]] = {} # sku -> (qty, unit_cents)
def add(self, sku, qty, unit_cents):
old_qty, _ = self._lines.get(sku, (0, unit_cents))
self._lines[sku] = (old_qty + qty, unit_cents)
def total_cents(self):
return sum(qty * unit for qty, unit in self._lines.values())
public final class Cart {
private final Map<String, Line> lines = new HashMap<>();
public void add(String sku, int qty, int unitCents) {
lines.merge(sku, new Line(qty, unitCents), Line::plus);
}
public long totalCents() {
return lines.values().stream().mapToLong(Line::amountCents).sum();
}
}
Checkout, invoice and refunds all now say cart.total_cents(). They were never
interested in the list; they were interested in the total, and the list was the
only way to get it.
What Java enforces and Python does not
Java's modifiers are checked by the compiler, and there are four, not three:
| Modifier | Who can touch it |
|---|---|
private | the same top-level class, including its nested classes |
| (none) | any class in the same package — the default, often written by accident |
protected | subclasses and every class in the same package |
public | everything on the classpath |
The row people get wrong is protected. It is not "subclasses only"; it also
grants package access, which means a protected field is readable by any class
someone drops into the same package. If you want "subclasses only", Java does
not have it.
Python has none of this. A leading underscore is a message to a human, and a
double underscore is not access control either — it is name mangling, which
rewrites self.__lines to self._Cart__lines so a subclass that happens to
pick the same attribute name does not collide with the parent:
class Cart:
def __init__(self):
self.__lines = {}
c = Cart()
c.__lines # AttributeError
c._Cart__lines # {} — the same object, one rename away
So Python enforces nothing, and pretending otherwise is worse than admitting it.
What Python has instead is a convention strong enough that a code review will
flag c._Cart__lines, and tooling that treats an underscore-prefixed name as
outside the public surface. Encapsulation in Python is a design decision
supported by review, not a promise the runtime keeps.
C++ changes one part of the answer. private is enforced at compile time like
Java, but the private members still appear in the header, so changing a private
field forces every translation unit that includes the header to recompile. That
is why library authors reach for the pointer-to-implementation idiom: the header
declares one opaque pointer, and the fields live in the .cpp file where no
caller can see them:
// cart.h — every includer sees this and nothing else
class Cart {
public:
Cart();
~Cart();
void add(std::string sku, int qty, int unit_cents);
long total_cents() const;
private:
struct Impl; // declared here, defined in cart.cpp
std::unique_ptr<Impl> impl_;
};
Change the storage from a vector to a map and only cart.cpp recompiles.
Encapsulation there buys build time, not just correctness.
A getter and a setter for every field is a public field
The reflex is to make each field private and generate an accessor pair. That changes nothing:
public int getCents() { return cents; }
public void setCents(int c) { this.cents = c; }
A caller writing a.setCents(a.getCents() + b.getCents()) knows the money is
stored in integer cents, knows the unit, and will break the day it becomes a
decimal. The representation is as exposed as it was; it now takes two more
methods to expose it.
The cost is not usually speed. The JVM inlines a trivial accessor once the call
site is hot, so in Java the overhead really is zero and the whole objection is
about design. In CPython it is not free — p.get_x() builds a bound method and
a frame where p.x is a dictionary load, roughly an order of magnitude apart —
which is why Python uses @property when a field later needs logic, and plain
attributes until then.
The question worth asking about a setter is different: does the object have any
invariant this setter can break? If Cart has one — line quantities are
positive — then setLines hands that invariant to every caller. Delete the
setter and give the object the operation instead.
Tell, do not ask
The rule that produces encapsulation without any thinking: when you find yourself reading state out of an object, deciding something, and writing state back, move all three onto the object.
# Ask: the rule lives at every call site
if account.get_balance() >= amount:
account.set_balance(account.get_balance() - amount)
# Tell: the rule lives once, where the balance does
account.withdraw(amount)
The asking version duplicates the overdraft rule at every call site, so a change
to it costs one edit per site, and it is also a check-then-act sequence that two
threads can interleave — the same read-modify-write hazard covered in
concurrency.
Only withdraw can make that atomic, because only withdraw owns both steps.
Tell-do-not-ask is not absolute. A value object exists to be read: a Money
that refuses to tell you its amount cannot be formatted or serialised. The
distinction is that reading a value is fine, while reading a value in order to
make a decision the object should have made is not.
In an interview
You will be asked to define encapsulation, and the definition is not what is being scored. Everyone can say "data hiding". What separates answers is whether you can name the thing it buys.
Say the count: "Encapsulation means the representation can change without
editing callers. If I expose the list, a change to it costs one edit per call
site; if I expose total(), it costs one." Then name the modifier subtlety —
that Java's protected also means package-visible, and that Python enforces
nothing and relies on convention — because it shows you have read the rules
rather than absorbed them.
The mistake that loses points: offering getters and setters as the mechanism. An interviewer who hears "make fields private and add getters and setters" has learned that you can apply the ritual, and the follow-up — "what does that hide?" — usually ends the topic badly. Hiding is what abstraction is not disentangles next.
Check yourself
A class has six private fields and twelve accessors that do nothing but read
and write them. One field changes from int cents to a decimal. How many files
do you edit?
Every file that calls the pair for that field, plus the class. The accessors bought nothing: the type is in the signature, so the compiler points at each call site instead of hiding it. Private plus accessors is a public field with extra syntax.
Python cannot enforce privacy. Does that mean a Python class cannot be encapsulated?
No. Encapsulation is a property of the design — how many places know the representation — not of the runtime. A Python class where only its own methods touch
_linesis encapsulated; a Java class with a public getter returning the live internal list is not, whatever the compiler permits.
You see if order.get_status() == "PAID": order.set_status("SHIPPED") in four
services. Name two defects and the one change that fixes both.
The transition rule is duplicated four times, so a new rule costs four edits; and the check and the write are separate, so two callers can both see
PAIDand both ship. Replace it withorder.ship(), which owns the rule and can make the transition atomic.