Encapsulation and abstraction6 min · 5 of 16

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.

Encapsulation is measured in edit sites. Move the sum onto the cart and the representation change stops at the class boundary.
Three callers summing a public list, then the same three callers asking the cart for a totalBEFORE · THE LIST IS PUBLICCheckoutInvoiceRefundscart.linesa listeach caller sums the list itself3 callers + 1 class = 4 edit sitesAFTER · THE CART OWNS THE SUMCheckoutInvoiceRefundscart.total()the only readercart._linesnow a dict1 edit site

Scroll to zoom · drag to pan · 0 fits · Esc closes

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:

ModifierWho can touch it
privatethe same top-level class, including its nested classes
(none)any class in the same package — the default, often written by accident
protectedsubclasses and every class in the same package
publiceverything 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 _lines is 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 PAID and both ship. Replace it with order.ship(), which owns the rule and can make the transition atomic.