Object-Oriented Programming
1 · The lesson
readA class is a blueprint for bundling related state and the behaviour that acts on it. When you find yourself passing the same dictionary into a dozen functions — update_balance(account, ...), withdraw(account, ...), format_account(account) — that's the moment to organise them into a class. The data and the operations belong together.
This lesson covers the syntax, the dunder methods that make a class feel native, the difference between class and instance state, and the decorators (@classmethod, @staticmethod, @property) that round out a well-designed object.
1. Why OOP — When State and Behaviour Belong Together
Pure functions are the cleanest tool when you have inputs and want outputs. But the moment data has identity — this account, that user, my shopping cart — and many operations mutate it over time, a class organises the code around the thing being modelled.
# Procedural — works, but everything orbits a loose dict def make_account(owner, balance=0): return {"owner": owner, "balance": balance} def deposit(account, amount): account["balance"] += amount def withdraw(account, amount): if amount > account["balance"]: raise ValueError("insufficient funds") account["balance"] -= amount acc = make_account("Linus", 100) deposit(acc, 50) withdraw(acc, 30)
The dict carries no contract — anyone can write acc["balnace"] = 999 (typo) and the bug surfaces three functions later. The OOP version keeps the operations attached to the data:
class Account: def __init__(self, owner, balance=0): self.owner = owner self.balance = balance def deposit(self, amount): self.balance += amount def withdraw(self, amount): if amount > self.balance: raise ValueError("insufficient funds") self.balance -= amount acc = Account("Linus", 100) acc.deposit(50) acc.withdraw(30)
Same logic, fewer moving parts, and acc.balnace would raise AttributeError immediately.
2. class, __init__, and self
class Point: def __init__(self, x, y): self.x = x self.y = y p = Point(3, 4) print(p.x, p.y) # 3 4
__init__ runs once when you call Point(3, 4) — Python creates a new empty instance, passes it as the first argument (self), and you populate it. self isn't a keyword; it's just the conventional name for "the instance being operated on". You could call it this or me — your reviewers would not be amused.
Every method gets self as its first parameter. Python wires it in automatically when you call p.method(...).
class Point: def __init__(self, x, y): self.x = x self.y = y def translate(self, dx, dy): self.x += dx self.y += dy p = Point(3, 4) p.translate(1, 2) # Python passes p as self print(p.x, p.y) # 4 6
p.translate(1, 2) is sugar for Point.translate(p, 1, 2).
3. __repr__ vs __str__
By default, printing an instance gives you a useless <__main__.Point object at 0x7f...>. Two dunders fix this:
__repr__— unambiguous, developer-facing. Ideally valid Python that recreates the object.__str__— friendly, user-facing.
class Point: def __init__(self, x, y): self.x = x self.y = y def __repr__(self): return f"Point(x={self.x}, y={self.y})" def __str__(self): return f"({self.x}, {self.y})" p = Point(3, 4) print(repr(p)) # Point(x=3, y=4) print(str(p)) # (3, 4) print(p) # (3, 4) — print uses __str__ [p, p] # [Point(x=3, y=4), Point(x=3, y=4)] — containers use __repr__
If you only define __repr__, Python falls back to it for str(...) too. Always define __repr__. Skip __str__ unless you genuinely have a separate user-facing representation.
4. __eq__ and Identity vs Equality
By default, a == b is true only when a and b are the same object in memory — same as a is b. Two distinct Point(3, 4) instances compare unequal.
class Point: def __init__(self, x, y): self.x = x self.y = y a = Point(3, 4) b = Point(3, 4) print(a == b) # False — different objects print(a is b) # False
Define __eq__ to compare by value:
class Point: def __init__(self, x, y): self.x = x self.y = y def __eq__(self, other): if not isinstance(other, Point): return NotImplemented return self.x == other.x and self.y == other.y print(Point(3, 4) == Point(3, 4)) # True print(Point(3, 4) == "hello") # False — NotImplemented falls back
Return NotImplemented (the sentinel, not False) when the types don't match — Python will try the other operand's __eq__ before giving up.
One sharp edge: once you define __eq__, Python sets __hash__ to None and your instances become unhashable (can't go in a set or dict key). If you want both, define __hash__ returning a hash of the same fields you compared, or use @dataclass(frozen=True) — covered in the dataclasses lesson.
5. Class Attributes vs Instance Attributes
Variables defined inside __init__ (with self.) belong to each instance. Variables defined at the class body level are shared by every instance.
class Dog: species = "Canis familiaris" # class attribute — shared def __init__(self, name): self.name = name # instance attribute — per-dog a = Dog("Rex") b = Dog("Buddy") print(a.species, b.species) # Canis familiaris Canis familiaris print(a.name, b.name) # Rex Buddy
Class attributes are great for constants and defaults. They are dangerous when mutable:
class Team: members = [] # BAD — one list shared by every Team def add(self, name): self.members.append(name) a, b = Team(), Team() a.add("Linus") b.add("Ada") print(a.members) # ['Linus', 'Ada'] — they share the list! print(b.members) # ['Linus', 'Ada'] print(a.members is b.members) # True
Same trap as the mutable-default-arg problem from functions. Fix it by giving each instance its own list in __init__:
class Team: def __init__(self): self.members = []
6. @classmethod and @staticmethod
Three flavours of method, distinguished by what they receive as their first argument:
- Regular method →
self(the instance). @classmethod→cls(the class itself). For alternative constructors and class-level operations.@staticmethod→ nothing automatic. For utility functions that logically belong to the class but don't touch instance or class state.
class Date: def __init__(self, year, month, day): self.year, self.month, self.day = year, month, day @classmethod def from_string(cls, s): """Alternative constructor — parses '2026-05-14'.""" y, m, d = s.split("-") return cls(int(y), int(m), int(d)) # cls(...) — works for subclasses too @staticmethod def is_leap(year): """Pure utility — no self, no cls.""" return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) d = Date.from_string("2026-05-14") print(d.year, d.month, d.day) # 2026 5 14 print(Date.is_leap(2024)) # True
Use @classmethod for alternative constructors (from_csv_row, from_json, from_dict) — cls(...) ensures subclasses get their own type back. Use @staticmethod sparingly; if the function doesn't touch class state, a module-level function is often clearer.
7. @property — Pythonic Getters
Java-style getters and setters (getName(), setName()) are not Pythonic. @property lets attributes look like attributes but run code on access.
class Circle: def __init__(self, radius): self.radius = radius @property def area(self): return 3.14159 * self.radius ** 2 @property def diameter(self): return self.radius * 2 c = Circle(5) print(c.area) # 78.53975 — note: no parentheses print(c.diameter) # 10
Read-only by default — c.area = 100 raises AttributeError. Add a setter when you genuinely need one:
class Temperature: def __init__(self, celsius): self._celsius = celsius @property def celsius(self): return self._celsius @celsius.setter def celsius(self, value): if value < -273.15: raise ValueError("below absolute zero") self._celsius = value @property def fahrenheit(self): return self._celsius * 9 / 5 + 32 t = Temperature(20) t.celsius = 25 # runs the setter, validates print(t.fahrenheit) # 77.0 # t.celsius = -300 # ValueError
Convention: store the backing value in self._name (single underscore = "private by convention") and expose self.name as the property. Don't reach for @property on day one — start with plain attributes and promote to a property only when you need validation or a derived value.
8. The @dataclass Shortcut
A class that just holds data with __init__, __repr__, and __eq__ is so common that the standard library generates it for you:
from dataclasses import dataclass @dataclass class Point: x: int y: int p = Point(3, 4) print(p) # Point(x=3, y=4) print(p == Point(3, 4)) # True
Three lines do what would take fifteen by hand. We cover @dataclass in depth in the next lesson — including frozen=True, field(default_factory=...), and when not to use it.
Common Mistakes
1. Mutable class attributes shared across instances — see Section 5. The list/dict/set defined at class-body level is one object shared by every instance. If you want per-instance state, initialise it in __init__.
2. Forgetting self.
class Counter: def __init__(self): count = 0 # local variable — discarded when __init__ returns! def bump(self): self.count += 1 # AttributeError: 'Counter' object has no attribute 'count'
Every instance attribute needs the self. prefix on the assignment.
3. Forgetting super().__init__() in subclasses — a subclass that defines its own __init__ and forgets to call the parent's leaves the parent's attributes uninitialised. We unpack this fully in inheritance.
4. Confusing class vs instance access
class Counter: total = 0 # class attribute def bump(self): self.total += 1 # ⚠️ creates an instance attribute that shadows the class one a = Counter() a.bump() print(a.total) # 1 print(Counter.total) # 0 — class attribute untouched
self.total += 1 reads Counter.total (0), adds 1, and assigns to self.total — making a fresh instance attribute. If you genuinely want a shared counter, write Counter.total += 1.
5. Reaching for a class when a function would do. A class with one method and no state is just a function with extra ceremony. def parse(text): ... beats class Parser: def parse(self, text): ... until you have real state to manage.
🎯 Your Turn — A BankAccount Class
Build a BankAccount with the full set of OOP tools. It should:
1. Take account_number, owner, and optional balance (default 0) in __init__.
2. Track a transactions list — each entry a (kind, amount) tuple.
3. Have deposit(amount) and withdraw(amount) methods. Withdraw raises ValueError if amount exceeds the balance.
4. Have a __repr__ like BankAccount(account_number='A001', owner='Linus', balance=120).
5. Compare equal by account_number only (two accounts with the same number are "the same account").
6. Provide a @classmethod from_csv_row(row) that parses "A001,Linus,100" into a new account.
Skeleton:
class BankAccount: def __init__(self, account_number, owner, balance=0): # TODO 1: store the three attributes # TODO 2: initialise an empty transactions list (per-instance!) ... def deposit(self, amount): # TODO 3: increase balance, append ("deposit", amount) to transactions ... def withdraw(self, amount): # TODO 4: raise ValueError if amount > balance, else subtract and log ... def __repr__(self): ... def __eq__(self, other): # TODO 5: compare by account_number only; return NotImplemented for non-accounts ... @classmethod def from_csv_row(cls, row): # TODO 6: split on ",", convert balance to int, call cls(...) ... a = BankAccount("A001", "Linus", 100) a.deposit(50) a.withdraw(30) print(a) # BankAccount(account_number='A001', owner='Linus', balance=120) print(a.transactions) # [('deposit', 50), ('withdraw', 30)] b = BankAccount.from_csv_row("A001,Different Person,9999") print(a == b) # True — same account number
Hint 1 — Per-instance lists
Putself.transactions = [] inside __init__. If you put transactions = [] at class-body level, every account would share the same list — the bug from Section 5.
Hint 2 — Equality by one field
Inside__eq__, check isinstance(other, BankAccount) first. If not, return NotImplemented. Otherwise compare self.account_number == other.account_number.
Show full solution
class BankAccount: def __init__(self, account_number, owner, balance=0): self.account_number = account_number self.owner = owner self.balance = balance self.transactions = [] # per-instance — must live in __init__ def deposit(self, amount): if amount <= 0: raise ValueError("amount must be positive") self.balance += amount self.transactions.append(("deposit", amount)) def withdraw(self, amount): if amount <= 0: raise ValueError("amount must be positive") if amount > self.balance: raise ValueError("insufficient funds") self.balance -= amount self.transactions.append(("withdraw", amount)) def __repr__(self): return (f"BankAccount(account_number={self.account_number!r}, " f"owner={self.owner!r}, balance={self.balance})") def __eq__(self, other): if not isinstance(other, BankAccount): return NotImplemented return self.account_number == other.account_number def __hash__(self): return hash(self.account_number) # keeps instances usable as dict keys @classmethod def from_csv_row(cls, row): num, owner, balance = row.split(",") return cls(num.strip(), owner.strip(), int(balance.strip())) a = BankAccount("A001", "Linus", 100) a.deposit(50) a.withdraw(30) print(a) print(a.transactions) b = BankAccount.from_csv_row("A001, Different Person, 9999") print(a == b) # True print({a, b}) # {BankAccount(...)} — one entry, hash collapsed
The __hash__ definition is what lets you put accounts into a set even after overriding __eq__. Without it, you'd get TypeError: unhashable type: 'BankAccount' the moment you tried.
What You Learned
- A class packages state (attributes) and behaviour (methods) under one name. Use one when data has identity and many operations act on it.
__init__initialises an instance;selfis the conventional name for the instance.__repr__is the unambiguous developer view;__str__is the user-facing one. Define__repr__always.- Default
==is identity.__eq__lets you compare by value — and unhashes the class unless you also define__hash__. - Class attributes are shared; instance attributes (
self.x) are per-object. Never put a mutable default at class-body level. @classmethodfor alternative constructors usingcls(...).@staticmethodfor class-related utilities.@propertyfor computed/derived attributes.
Next: Inheritance & Polymorphism — building hierarchies, the super() call, and when not to inherit.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code actually did, not just whether a test passed.