Search Tech Journey

Find topics, journeys and posts

6-month learning plan9 / 130
back to blog
pythonbeginner 15m read

S009 · Classes & Objects — the OOP Mental Model

Bundling data with behaviour, the way OOP actually clicks.

Module M01: Python Foundations · Session 9 of 130 · Track: SW 🏗️

What you'll be able to do after this session

  • Explain Classes & Objects to a friend in one minute, out loud, no notes.
  • Recognise it when you see it in real code / systems / papers.
  • Complete the hands-on exercise at the end without looking up help.

Prerequisites

If you can't answer these in 30 seconds each, redo the linked session first:


Pre-read (skim before the session)


(a) Intuition · 5 min

The one-paragraph mental model. Why this concept exists, what problem it solves, and the analogy that makes it stick.

In one sentence: Bundling data with behaviour, the way OOP actually clicks.

An object is a bundle of data with the functions that operate on it. A list is an object: the data is the items, and the functions are append, sort, pop. When you write mylist.append(3), you're calling a function that already "knows" which list it belongs to. That's it. That's OOP.

A class is the recipe; an instance is a cake baked from it. class Dog: defines the shape; snoopy = Dog("Snoopy", 5) creates one instance. You can create thousands of Dog instances, each with their own name and age but sharing the same methods.

Why bundle data with functions? Because it turns "here's a dict, hope the caller uses the right keys and the right function on it" into "here's an object; call .method() and IDE + type checker will guide you." It also lets you enforce invariants (a BankAccount object refuses to let its balance go negative) and swap implementations (any File object with .read() and .close() works, whether it's on disk or in S3).

Gotcha to carry forever: the first parameter of every method is self — the instance itself. Python passes it implicitly when you write snoopy.bark(), but you write it explicitly in the definition. And class variables (defined at class level) are shared across all instances, which is a great source of bugs if the class variable is mutable (a list or dict).


(b) Visual walkthrough · 15 min

Diagrams, worked examples, step-by-step visuals.

Classes and instances:

The four dunder methods you'll write most often:

DunderWhen it's calledWhy you'd override
__init__(self, ...)On construction (Dog(...))Initialise state.
__repr__(self)repr(x), in the REPL, in tracebacksUnambiguous developer-facing string.
__str__(self)str(x), print(x)Human-friendly rendering.
__eq__(self, other)a == bValue equality, not identity.

Worked example — a BankAccount that enforces invariants:

class BankAccount:
    '''A minimal account with an overdraft guard.'''
 
    interest_rate = 0.04              # CLASS variable — shared by all accounts
 
    def __init__(self, owner: str, balance: float = 0.0):
        self.owner    = owner         # INSTANCE variable
        self._balance = balance       # leading _ = "internal, don't touch"
 
    def deposit(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("deposit must be positive")
        self._balance += amount
 
    def withdraw(self, amount: float) -> None:
        if amount > self._balance:
            raise ValueError(f"insufficient funds: have {self._balance}, need {amount}")
        self._balance -= amount
 
    @property
    def balance(self) -> float:       # read-only access via a.balance (no parens)
        return self._balance
 
    def __repr__(self) -> str:
        return f"BankAccount(owner={self.owner!r}, balance={self._balance})"
 
 
a = BankAccount("Dinesh", 1000)
a.deposit(500)
try:
    a.withdraw(9999)
except ValueError as e:
    print("blocked:", e)
print(a, "balance:", a.balance)

Notice: (1) _balance is private-by-convention (Python doesn't enforce it), (2) @property makes a.balance look like an attribute but run a function, (3) __repr__ is what you see in the REPL and in tracebacks — always define it for domain objects.

Instance vs class vs static methods:

KindDecoratorFirst argTypical use
Instance method(none)selfReads/writes instance state.
Class method@classmethodclsAlternative constructors: Account.from_json(s).
Static method@staticmethod(none)A helper related to the class but not needing state.

(c) Hands-on · 20 min

Code you type and run. Not pseudo-code — real, runnable, copy-pasteable.

Type this in and run it. Predict output before running.

# s009_classes.py
 
class Dog:
    species = "canis familiaris"           # class variable — shared
 
    def __init__(self, name: str, age: int):
        self.name = name                    # instance variables — unique per dog
        self.age  = age
 
    def bark(self) -> str:
        return f"Woof from {self.name}"
 
    def __repr__(self) -> str:
        return f"Dog(name={self.name!r}, age={self.age})"
 
 
snoopy = Dog("Snoopy", 5)
rex    = Dog("Rex", 2)
 
print(snoopy)                # uses __repr__
print(snoopy.bark())
print(rex.bark())
print(Dog.species, snoopy.species, rex.species)   # all point to same class var
 
# Class vars vs instance vars — a classic surprise.
Dog.species = "canis lupus"                # change on the CLASS
print(snoopy.species, rex.species)         # both see it
 
snoopy.species = "canis dingus"            # creates INSTANCE attr, shadows class attr
print(snoopy.species, rex.species, Dog.species)
 
# ---------------- BankAccount with invariants ----------------
class BankAccount:
    interest_rate = 0.04
 
    def __init__(self, owner: str, balance: float = 0.0):
        self.owner    = owner
        self._balance = balance
 
    def deposit(self, amount: float) -> None:
        if amount <= 0: raise ValueError("deposit must be positive")
        self._balance += amount
 
    def withdraw(self, amount: float) -> None:
        if amount > self._balance:
            raise ValueError(f"insufficient funds")
        self._balance -= amount
 
    @property
    def balance(self) -> float:
        return self._balance
 
    @classmethod
    def joint(cls, owner_a: str, owner_b: str, balance: float = 0.0) -> "BankAccount":
        '''Alternative constructor: joint account.'''
        return cls(f"{owner_a} & {owner_b}", balance)
 
    @staticmethod
    def is_valid_owner(name: str) -> bool:
        return bool(name) and name.strip() == name
 
    def __repr__(self) -> str:
        return f"BankAccount(owner={self.owner!r}, balance={self._balance})"
 
 
a = BankAccount("Dinesh", 1000)
a.deposit(500)
print(a, a.balance)
 
j = BankAccount.joint("Dinesh", "Anusha", 5000)
print(j)
 
print(BankAccount.is_valid_owner("Dinesh"))
print(BankAccount.is_valid_owner(" spaces "))
 
try:
    a.withdraw(9999)
except ValueError as e:
    print("blocked:", e)
 
# ---------------- The mutable-class-var trap ----------------
class BadInbox:
    messages = []              # SHARED across every instance — BUG magnet
 
    def add(self, m): self.messages.append(m)
 
x = BadInbox(); y = BadInbox()
x.add("hi from x")
print("y sees:", y.messages)   # ['hi from x'] — surprise!
 
class GoodInbox:
    def __init__(self):
        self.messages = []     # per-instance list — SAFE
 
    def add(self, m): self.messages.append(m)
 
x = GoodInbox(); y = GoodInbox()
x.add("hi from x")
print("y sees:", y.messages)   # []

Checklist — what to observe:

  1. print(snoopy) calls __repr__; without it you'd see <Dog object at 0x…> — useless.
  2. Changing Dog.species updates it for all instances that haven't shadowed it.
  3. Assigning snoopy.species = … creates an instance attribute that shadows the class one.
  4. BankAccount.joint(...) is called on the class — it returns a new instance.
  5. BadInbox shares one list across all instances; GoodInbox gives each instance its own. This is the OOP-flavoured version of the mutable-default-argument bug from S007.

Try this modification: add a __eq__ method to BankAccount so that two accounts with the same owner and balance compare equal. Then create two accounts and try == and is. Bonus: add __hash__ so accounts can be put into a set.


(d) Production reality · 10 min

How this shows up in real systems, gotchas, war stories, common bugs.

War story — the "silent shared state" bug. A SaaS startup had class Config: features = {} as a class attribute. Multiple Config() instances all mutated the same dict, so a feature-flag override in one tenant leaked to every tenant. Two customer support tickets later they wrapped it in def __init__(self): self.features = {}. Rule: never put a mutable value at class level unless you explicitly want it shared.

War story — the "God object." A payment class at a fintech grew to 8 000 lines: class Payment handled charging, refunding, notifications, audit logging, retries, currency conversion, tax, and receipt generation. Nobody could refactor safely. The eventual fix was to split it into a small Payment dataclass (just the data) plus separate Charger, Refunder, Notifier, AuditLog classes. Composition over inheritance (S010) is the way.

How top teams use classes in modern Python:

  • @dataclass (or Pydantic) for pure data. If your class is 90 % self.x = x, use @dataclass — it generates __init__, __repr__, __eq__ for you (S014).
  • Type hints on every attribute and method signature. Dropbox typed 4 million lines this way; mypy catches whole categories of bugs.
  • @property for read-only computed attributes. Prefer account.balance over account.get_balance().
  • Private by convention (_x). Python doesn't enforce it, but linters and reviewers will. Reserve __x (name-mangled) for genuine "leave me alone."
  • __slots__ if you're creating millions of tiny instances — cuts memory 30–50 %. Used heavily inside NumPy, Django's ORM, Instagram's models.

Testability guideline (from Google): if your class needs to hit the network, database, or filesystem in __init__, it's very hard to unit-test. Inject those dependencies (def __init__(self, db=None):) so tests can pass in fakes. This is the entire premise of the SOLID "Dependency Inversion" principle you'll see in Java shops.

Common bugs:

  • Forgetting self in a method signature → TypeError: method() takes 1 positional argument but 2 were given.
  • Mutating a class-level list/dict from an instance (see BadInbox above).
  • Overriding a method but forgetting to call super().__init__() in the subclass (S010).
  • Using is instead of == to compare domain objects — always define __eq__ and use ==.

(e) Quiz + exercise · 10 min

  1. What is self, and why must it be the first parameter of every instance method?
  2. What's the difference between a class variable and an instance variable?
  3. When should you define __repr__ vs __str__?
  4. What does @property do, and why prefer it over a get_balance() method?
  5. Why is class Foo: items = [] almost always a bug when Foo is instantiated multiple times?

Stretch problem (connects to S007/S008): implement a WordFrequency class whose constructor takes a text string, stores a Counter internally, and exposes methods top_n(n) and count_of(word). Then override __repr__ to show the top-3 words, and __eq__ so two instances are equal iff their counters are equal.


Explain-out-loud test

If you can't teach these 3 things to a friend without notes, redo the session:

  1. What is Classes & Objects? (one sentence, no jargon)
  2. When would you use it? (one concrete example)
  3. When would you NOT use it? (one anti-pattern)

What comes next


Part of a 130-session evergreen learning series. Session structure: (a) intuition · (b) visual walkthrough · (c) hands-on · (d) production reality · (e) quiz + exercise. Duration: 60 minutes.

More from M01 · Python Foundations

all modules →
  1. 005Python Variables & Types — Mental Model of Memory
  2. 006Control Flow — if/else, loops, comprehensions
  3. 007Functions — arguments, scope, closures
  4. 008Data Structures — list, tuple, dict, set (when to use what)
  5. 010Inheritance, Composition & Polymorphism
  6. 011Errors, Exceptions & Debugging with pdb