Objects and classes6 min · 2 of 16

State and behaviour

Write a class as fields plus the operations allowed to change them, and catch the two breaks: shared class state and methods that touch no state.

A class is two things fastened together: a set of fields, and the complete list of operations allowed to write them. The second half is the part that earns its keep. If any code anywhere can assign to a field, the class is a struct with extra syntax, and every bug about that field is a search of the whole codebase rather than a read of one file.

The same class, twice

Money is held in paise as an integer, never a float, so that no rounding can happen behind a method's back.

class BankAccount:
    def __init__(self, owner_id, opening_paise=0):
        self._owner_id = owner_id
        self._balance_paise = opening_paise
        self._entries = []

    def deposit(self, paise):
        if paise <= 0:
            raise ValueError("deposit must be positive")
        self._balance_paise += paise
        self._entries.append(("deposit", paise))

    def withdraw(self, paise):
        if paise <= 0:
            raise ValueError("withdrawal must be positive")
        if paise > self._balance_paise:
            raise ValueError("insufficient funds")
        self._balance_paise -= paise
        self._entries.append(("withdraw", paise))

    @property
    def balance_paise(self):
        return self._balance_paise
public final class BankAccount {
    private final String ownerId;
    private long balancePaise;
    private final List<Entry> entries = new ArrayList<>();

    public BankAccount(String ownerId, long openingPaise) {
        this.ownerId = ownerId;
        this.balancePaise = openingPaise;
    }

    public void deposit(long paise) {
        if (paise <= 0) throw new IllegalArgumentException("deposit must be positive");
        balancePaise += paise;
        entries.add(new Entry("deposit", paise));
    }

    public void withdraw(long paise) {
        if (paise <= 0) throw new IllegalArgumentException("withdrawal must be positive");
        if (paise > balancePaise) throw new IllegalArgumentException("insufficient funds");
        balancePaise -= paise;
        entries.add(new Entry("withdraw", paise));
    }

    public long balancePaise() { return balancePaise; }
}

The languages differ on enforcement, not on design. Java's private is checked by the compiler; Python's leading underscore is a convention that a determined caller can ignore. Both say the same thing to a reader: the balance and the entry log move together, and deposit and withdraw are the only two places they do — each guards the amount, applies it, and writes the log entry before it returns.

The fields have one door. That is the whole difference between a class and a record with functions near it.
A BankAccount class drawn as two compartments: three fields, and the three methods that are the only code allowed to write themBankAccountstatebalance_paise: intowner_id: strentries: listbehaviourdeposit(paise)withdraw(paise)balance_paise()Any callerassigns a fieldcalls a methodthe only codethat writes afieldThree fields, three operations, one door. Change the balance and youedit one method, not the codebase.

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

Instance state versus class state

An attribute assigned on self exists once per object. An attribute assigned in the class body exists once, full stop, and every instance reads the same copy. Java spells the second one static.

class Playlist:
    library_version = 3          # one copy, shared by every Playlist
    def __init__(self, name):
        self.name = name         # one copy per instance
        self.tracks = []
class Playlist {
    static int libraryVersion = 3;                       // one copy, in the class
    private final String name;                           // one copy per instance
    private final List<String> tracks = new ArrayList<>();

    // a blank final field must be assigned by every constructor
    Playlist(String name) { this.name = name; }
}

The choice is usually about memory or about truth. A 1 KB lookup table copied into every instance costs 1 KB × 1 million objects = 1 GB; held as class state it costs 1 KB. But a mutable class attribute is global state wearing a class name, and it is shared by every thread in the process, so treat class state as constants unless you have a reason.

The default argument that everyone shares

Python evaluates a default argument once, when the def line runs, not once per call:

class Playlist:
    def __init__(self, name, tracks=[]):   # evaluated once, at import time
        self.name = name
        self.tracks = tracks               # every playlist gets the same list

    def add_track(self, title):
        self.tracks.append(title)          # appends to whatever list self.tracks names

Every Playlist built without an explicit list now points at one list object. Suppose a service creates one playlist per request, calls add_track once on it, and takes 1 million requests a day: 1,000,000 ÷ 86,400 s ≈ 12 requests per second on average, and one append per request means that after a day the single list holds roughly a million tracks that belong to everybody.

Little's Law says how many of those appends are in flight together: concurrency = arrival rate × latency, so at a 5× peak of 12 × 5 = 60 QPS and 200 ms per request, 60 × 0.2 = 12 requests are running at once. Nothing is corrupted by that — list.append is atomic under CPython's GIL and no track is lost. The failure is not a data race, it is shared data: those twelve requests, and every request before and after them, read and write one list, so every playlist in the process shows every other user's tracks.

The fix is to build the mutable thing inside the constructor:

def __init__(self, name, tracks=None):
    self.name = name
    self.tracks = list(tracks) if tracks is not None else []

Java has no equivalent trap, because field initialisers run once per construction. C++ has none either: a default argument there is evaluated at each call, so std::vector<std::string> tracks = {} produces a fresh vector every time. The bug is Python's alone — but its shape, one mutable object reachable from many owners, is not, and a static final List in Java that an instance method appends to is the identical defect spelled differently.

Methods, functions, and the argument you cannot see

A method is a function whose first argument is the object. Python shows it: account.deposit(500) is BankAccount.deposit(account, 500), and the two forms are interchangeable. Java hides it behind this, and C++ hides the same pointer. Nothing deep is happening — the dot is a lookup that supplies one argument.

That makes a useful test available. Count the fields a method reads or writes. If the answer is zero, it is a plain function that has been parked inside a class:

def format_paise(paise):        # touches no account
    return f"₹{paise // 100}.{paise % 100:02d}"

Leave it at module level, or mark it @staticmethod in Python and static in Java to say so out loud. The cheap check is whether you can call it in a test without constructing an object. If you can, the object was never part of it.

The same count run over a whole class tells you when to split. If a class has eight fields and each method touches one or two, with no method touching more than a third of them, you are looking at three classes that share a file. Splitting them shrinks what any one invariant has to cover.

In an interview

You are being tested on whether you can name the fields a class owns and the operations that maintain them, in one breath. Practise the sentence: "The state is the balance and the entry log. The only operations that write them are deposit and withdraw, and both keep the log in step with the balance, so no caller can move one without the other."

The mistake that loses points: presenting a class that is fields plus a getter and a setter for each. A setter per field is a public field with three extra lines of ceremony — you have written a record, given up every guarantee a class can make, and you will not be able to answer "what stops the balance going negative?" Say which fields have no setter and why, and the question answers itself.

Interviewers also listen for whether you know the difference between per-object and per-class state, usually by asking where a counter of live objects should live. Answer with the memory arithmetic, not with a preference.

Check yourself

A Playlist constructor has tracks=[] as a default. The service builds one playlist per request, adds exactly one track to it, and handles 1 million requests a day. What is in that list after a day, and what is the one-line fix?

One append per request, so 1,000,000 ÷ 86,400 s ≈ 12 appends per second, and all of them land on the single list created when the def line ran. It ends the day holding roughly a million tracks belonging to every user at once. Default to None and build a fresh list inside __init__.

A method on Invoice reads no field of self. Where does it belong, and what is the test that settles it?

At module level, or as a @staticmethod / static if it is closely tied to the type. The test: can you call it in a unit test without constructing an Invoice? If yes, the object was decoration.

You need a count of how many accounts the process has created. Instance attribute or class attribute?

Class attribute — but say how it is incremented, because the obvious spelling is wrong. self.created += 1 inside __init__ reads the class value and then binds the result on the instance, so after two objects Acct.created is still 0 while each object carries its own shadowing created == 1 in its __dict__: exactly the per-instance counter the class attribute was chosen to avoid, and it fails silently. The write has to name a class. Acct.created += 1 keeps one tally for the type and every subclass under it; type(self).created += 1 shadows one level up instead, so each subclass starts its own tally from whatever the base held at the time — three objects across Acct and Sub leave Acct.created at 1 and Sub.created at 3. Choose which of those two you meant. The cost is the same either way: shared mutable state across threads, which needs a lock or an atomic, and which is why the identity of an object is a separate question from the values it holds.