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)
- 🎥 Intuition (5–15 min) · Object-Oriented Programming in 7 Minutes — Programming with Mosh — Fastest useful explainer of the OOP mental model.
- 🎥 Deep dive (30–60 min) · Python OOP Tutorial 1: Classes and Instances — Corey Schafer — The definitive Python-specific OOP walkthrough.
- 🎥 Hands-on demo (10–20 min) · Python OOP Tutorial 2: Class vs. Instance Variables — Corey Schafer — Clears up the top confusion in Python OOP.
- 📖 Canonical article · Python Docs — Classes (§9) — The official tutorial; short, dense, canonical.
- 📖 Book chapter / tutorial · Real Python — Object-Oriented Programming (OOP) in Python 3 — Beginner-friendly walkthrough with worked examples.
- 📖 Engineering blog · Dropbox Engineering — Our journey to type checking 4 million lines of Python — Why classes + type hints scale to millions of lines.
(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:
| Dunder | When it's called | Why you'd override |
|---|---|---|
__init__(self, ...) | On construction (Dog(...)) | Initialise state. |
__repr__(self) | repr(x), in the REPL, in tracebacks | Unambiguous developer-facing string. |
__str__(self) | str(x), print(x) | Human-friendly rendering. |
__eq__(self, other) | a == b | Value 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:
| Kind | Decorator | First arg | Typical use |
|---|---|---|---|
| Instance method | (none) | self | Reads/writes instance state. |
| Class method | @classmethod | cls | Alternative 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:
print(snoopy)calls__repr__; without it you'd see<Dog object at 0x…>— useless.- Changing
Dog.speciesupdates it for all instances that haven't shadowed it. - Assigning
snoopy.species = …creates an instance attribute that shadows the class one. BankAccount.joint(...)is called on the class — it returns a new instance.BadInboxshares one list across all instances;GoodInboxgives 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.
@propertyfor read-only computed attributes. Preferaccount.balanceoveraccount.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
selfin 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
isinstead of==to compare domain objects — always define__eq__and use==.
(e) Quiz + exercise · 10 min
- What is
self, and why must it be the first parameter of every instance method? - What's the difference between a class variable and an instance variable?
- When should you define
__repr__vs__str__? - What does
@propertydo, and why prefer it over aget_balance()method? - Why is
class Foo: items = []almost always a bug whenFoois 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:
- What is Classes & Objects? (one sentence, no jargon)
- When would you use it? (one concrete example)
- When would you NOT use it? (one anti-pattern)
What comes next
- Next session (S010): Inheritance, Composition & Polymorphism
- Previous (S008): Data Structures — list, tuple, dict, set (when to use what)
- Hub: The 6-Month Learning Plan
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 →