Object-Oriented Programming
1 · The lesson
readA class is a blueprint for bundling related state and the behaviour that acts on it. Suppose you pass the same dictionary into a dozen functions: update_balance(account, ...), withdraw(account, ...), format_account(account). That is a sign you should 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 complete a well-designed class.
1. Why OOP: When State and Behaviour Belong Together
Pure functions are the cleanest tool when you have inputs and want outputs. But sometimes data has an identity (this account, that user, my shopping cart), and many operations change it over time. Then a class organises the code around the thing you are modelling.
# Procedural style: it works, but all the code depends on 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 has no fixed structure. 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)
The logic is the same, but the data and the operations are now in one place. And reading 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 your code fills it with attributes. self isn't a keyword. It is only the conventional name for "the instance this method works on". You could call it this or me, but other Python programmers would find your code harder to read.
Every method gets self as its first parameter. Python passes 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 a shorter way to write Point.translate(p, 1, 2).
3. __repr__ vs __str__
By default, printing an instance gives you something unhelpful like <__main__.Point object at 0x7f...>. Two dunder methods fix this:
__repr__: unambiguous, for developers. Ideally it is valid Python that recreates the object.__str__: friendly, for users.
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)] (a list shows its items with __repr__)
If you only define __repr__, Python uses it for str(...) too. Always define __repr__. Skip __str__ unless you really need a different text for users.
4. __eq__ and Identity vs Equality
By default, a == b is true only when a and b are the same object in memory, just like 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 (they are two 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 makes Python fall back to an identity check)
Return NotImplemented (a special built-in value, not False) when the types don't match. Python will then try the other operand's __eq__. If that also returns NotImplemented, == falls back to identity and gives False.
One thing to watch: once you define __eq__, Python sets __hash__ to None and your instances become unhashable (they can't go in a set or be a dict key). If you want both, define __hash__ to return 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 by all dogs def __init__(self, name): self.name = name # instance attribute, one for each 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 useful for constants and defaults. They cause bugs when the value is mutable:
class Team: members = [] # Bad: every Team shares this one list def add(self, name): self.members.append(name) a, b = Team(), Team() a.add("Linus") b.add("Ada") print(a.members) # ['Linus', 'Ada'] (both teams share the list!) print(b.members) # ['Linus', 'Ada'] print(a.members is b.members) # True
This is the same problem as the mutable default argument from functions. Fix it by giving each instance its own list in __init__:
class Team: def __init__(self): self.members = []
6. @classmethod and @staticmethod
There are three kinds of method. They differ in 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 that parses '2026-05-14'.""" y, m, d = s.split("-") return cls(int(y), int(m), int(d)) # using cls(...) makes this work for subclasses too @staticmethod def is_leap(year): """A plain helper. It gets no self and 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). Because it calls cls(...), a subclass that uses it gets an instance of the subclass 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 you write obj.name like a normal attribute, but Python runs a method when you read it.
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 after area) print(c.diameter) # 10
A property is read-only by default: c.area = 100 raises AttributeError. Add a setter when you really 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, which checks the value 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 use @property from the start. Begin with plain attributes, and change one into a property only when you need validation or a computed 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 detail in the dataclasses 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 # a local variable, thrown away 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. If a subclass defines its own __init__ and does not call the parent's, the parent's attributes are never set. We cover 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 hides the class one a = Counter() a.bump() print(a.total) # 1 print(Counter.total) # 0 (the class attribute is unchanged)
self.total += 1 reads Counter.total (0), adds 1, and assigns the result to self.total. That creates a new instance attribute. If you really want a shared counter, write Counter.total += 1.
5. Writing a class when a function would do. A class with one method and no state is a function with extra code around it. 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. Keep a transactions list. Each entry is 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 (one for each account!) ... 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. That is 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 = [] # one list per account, so create it 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, because a and b are equal)
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' as soon as 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, andselfis the conventional name for the instance.__repr__is the unambiguous developer view, and__str__is the user-facing one. Define__repr__always.- Default
==compares identity.__eq__lets you compare by value, but it makes instances unhashable unless you also define__hash__. - Class attributes are shared, but 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 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 did, as well as whether a test passed.
Found this useful? Share it with a friend who’s learning Python.