Backend / Python OOP / 02_classes_in_python.md

Classes in Python

Updated 4 interview angles 5 min read source
On this page8
  1. The class statement runs code
  2. Two namespaces, one lookup
  3. Methods are functions; self is the lookup
  4. Construction and identity
  5. When to write a class, and when not
  6. Where it breaks in production
  7. Related
  8. Interview angle

Classes in Python

A Python class is not a blueprint compiled ahead of time; it is an object built at runtime by executing a block of code, and everything an interviewer asks about classes — shared attributes, self, bound methods, the mutable class-attribute bug — follows from that. This note is the model. The individual mechanisms (magic methods, descriptors, inheritance, metaclasses) have their own notes, linked.

The class statement runs code

python
class Account:
    currency = "USD"                  # runs now, into the class namespace

    def __init__(self, owner, balance=0):
        self.owner = owner            # instance namespace, per object
        self.balance = balance

    def deposit(self, amount):
        self.balance += amount
        return self

print(type(Account))                  # <class 'type'>  - a class is an object
print(Account.__dict__.keys())        # 'currency', '__init__', 'deposit', ...

The body executes top to bottom in a fresh namespace; when it finishes, type(name, bases, namespace) turns that namespace into a class object and binds the name. That is why decorators and metaclasses can rewrite a class — it is just a dict until the last step (Metaclasses).

It is also why a class body is a strange scope: it is not a closure, so a comprehension inside it cannot see class-level names — Comprehension scope.

Two namespaces, one lookup

Every instance has its own __dict__; the class has another. Reading obj.x looks in the instance first, then the class, then each base in MRO order. Writing obj.x = v only ever touches the instance dict.

python
a, b = Account("ann"), Account("bob")
a.currency = "EUR"           # creates a.__dict__['currency']; class untouched
print(b.currency)            # USD  - still reads through to the class

class Bad:
    tags = []                # one list, shared by every instance
    def add(self, t):
        self.tags.append(t)  # reads the class list, mutates it in place

x, y = Bad(), Bad()
x.add("a")
print(y.tags)                # ['a']  - the classic bug

The asymmetry is the whole story: assignment shadows, mutation shares. A class attribute is a default; a mutable class attribute is a shared object, and the fix is to create it in __init__. The full lookup order, including where descriptors and __getattr__ cut in, is in Descriptors.

Methods are functions; self is the lookup

A def in a class body is a plain function stored in the class dict. Looking it up through an instance produces a bound method — the function with the instance pre-filled as its first argument:

python
print(Account.deposit)        # <function Account.deposit>
print(a.deposit)              # <bound method Account.deposit of <Account>>
a.deposit(10)                 # == Account.deposit(a, 10)

f = a.deposit                 # binding happens at lookup, so this holds `a`
f(5)                          # a.balance is now 15

self is a convention, not a keyword; it is the argument the binding supplies. This is why Account.deposit(a, 10) works, why storing a.deposit in a callback keeps a alive, and why @classmethod and @staticmethod exist — they change what the binding supplies (the class, or nothing): Difference Between staticmethod and classmethod in Python.

Kind First argument Use
instance method the instance anything that reads state
@classmethod the class alternative constructors
@staticmethod none a function namespaced by the class

Construction and identity

Account("ann") calls type.__call__, which runs __new__ to allocate and then __init__ to populate. You override __init__; __new__ is for immutable types and singletons, and is the thing candidates reach for too early (Difference Between __init__() and __new__() in Python).

Two objects are the same instance only if is says so; == is whatever __eq__ says, and defining __eq__ silently removes __hash__ — __eq__ and __hash__. __repr__, __eq__, ordering and the rest of the data-model hooks are in Magic Methods and Attributes in Python.

When to write a class, and when not

The senior signal is knowing the alternatives:

A class earns its place when state and the operations on it belong together, change together, and have invariants worth guarding — an Account that must never go negative, a connection that must be closed.

Where it breaks in production

  • Mutable class attributes. A list or dict on the class is one object shared by every instance and every test. Initialise it in __init__.
  • Attribute typos create attributes. self.balence = 0 is not an error; it is a new field. __slots__ or a dataclass with slots=True turns it into AttributeError — __slots__ — when it saves memory and what it breaks.
  • __eq__ without __hash__. The instances stop working as dict keys and set members, and the failure is far from the class that caused it.
  • Bound methods as callbacks. button.on_click(self.handle) keeps self alive for as long as the button; a leak that looks like nothing.
  • Deep hierarchies for reuse. Four levels of base class to share a helper method. Composition or a module-level function; inheritance is for substitutability, not code sharing — Inheritance in Python.

Interview angle 4

  • “Class attribute versus instance attribute?” - reads fall through from the instance dict to the class; writes only ever touch the instance. So a class attribute is a per-class default, and a mutable one is a shared object - the list every instance appends to. Fix: create it in __init__.
  • “What is self?” - the instance, supplied by method binding at lookup time. a.deposit(10) is Account.deposit(a, 10); the name is convention. Follow-up: what does @classmethod change? The binding supplies the class instead, which is what makes cls(...) construct the right subclass.
  • “When would you not write a class?” - data with no behaviour is a dataclass; a class with one method is a function; a class of static methods is a module; a fixed set of values is an Enum; an interface is a Protocol. A class is for state and operations that share invariants.
  • “Why does defining __eq__ break my set?” - Python sets __hash__ to None when a class defines __eq__ without it, because equal objects must hash equal and it cannot guarantee that for you. Define both, or use a frozen dataclass which does.