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.
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
defline ran. It ends the day holding roughly a million tracks belonging to every user at once. Default toNoneand 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/staticif it is closely tied to the type. The test: can you call it in a unit test without constructing anInvoice? 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 += 1inside__init__reads the class value and then binds the result on the instance, so after two objectsAcct.createdis still 0 while each object carries its own shadowingcreated == 1in 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 += 1keeps one tally for the type and every subclass under it;type(self).created += 1shadows one level up instead, so each subclass starts its own tally from whatever the base held at the time — three objects acrossAcctandSubleaveAcct.createdat 1 andSub.createdat 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.