🎯 Learning Objectives
- Understand what a class is and why OOP exists
- Define classes with
__init__and instance methods - Distinguish instance attributes, class attributes, and methods
- Use
@property,@staticmethod, and@classmethod - Implement the four OOP pillars: encapsulation, abstraction, inheritance, polymorphism (foundations)
- Write clean, idiomatic Python classes
Why Object-Oriented Programming?
As programs grow, managing dozens of related variables and functions becomes unwieldy. Object-Oriented Programming (OOP) solves this by bundling data (attributes) and behaviour (methods) into a single unit called an object.
# ── Without OOP: parallel lists, easy to mix up ──
names = ["Alice", "Bob"]
ages = [30, 25]
emails = ["alice@example.com", "bob@example.com"]
def greet_user(index):
print(f"Hi {names[index]}, you are {ages[index]}")
# ── With OOP: everything about a user lives in one object ──
class User:
def __init__(self, name, age, email):
self.name = name
self.age = age
self.email = email
def greet(self):
print(f"Hi {self.name}, you are {self.age}")
alice = User("Alice", 30, "alice@example.com")
alice.greet() # Hi Alice, you are 30
why_oop.py
Defining a Class
class BankAccount:
"""A simple bank account.""" # class docstring
# ── __init__: called when an instance is created ──
def __init__(self, owner, balance=0.0):
self.owner = owner # instance attribute
self.balance = balance # instance attribute
# ── Instance method: first parameter is always self ──
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self.balance += amount
return self.balance
def withdraw(self, amount):
if amount > self.balance:
raise ValueError("Insufficient funds")
self.balance -= amount
return self.balance
def __repr__(self):
return f"BankAccount(owner={self.owner!r}, balance={self.balance:.2f})"
# ── Creating instances ──
acc1 = BankAccount("Alice", 1000)
acc2 = BankAccount("Bob") # uses default balance=0
acc1.deposit(500)
acc1.withdraw(200)
print(acc1) # BankAccount(owner='Alice', balance=1300.00)
print(acc2) # BankAccount(owner='Bob', balance=0.00)
bank_account.py
self is just a convention — it's the first parameter of every instance
method and refers to the instance itself. Python passes it automatically when you call
acc1.deposit(500); you never write acc1.deposit(acc1, 500).
Instance vs Class Attributes
class Dog:
# ── Class attribute: shared by ALL instances ──
species = "Canis lupus familiaris"
count = 0
def __init__(self, name, breed):
# ── Instance attributes: unique per object ──
self.name = name
self.breed = breed
Dog.count += 1 # update the shared class counter
def bark(self):
return f"{self.name} says: Woof!"
fido = Dog("Fido", "Labrador")
buddy = Dog("Buddy", "Poodle")
print(fido.species) # Canis lupus familiaris — from class
print(fido.name) # Fido — from instance
print(Dog.count) # 2 — shared counter
print(fido.count) # 2 — instance lookup falls back to class
print(fido.bark()) # Fido says: Woof!
class_vs_instance_attr.py
__init__, not at class level.
# ❌ Bug: shared mutable class attribute
class Team:
members = [] # ALL Team instances share this list!
t1 = Team()
t2 = Team()
t1.members.append("Alice")
print(t2.members) # ['Alice'] — surprise!
# ✓ Fix: initialise in __init__
class Team:
def __init__(self):
self.members = [] # each instance gets its own list
t1 = Team()
t2 = Team()
t1.members.append("Alice")
print(t2.members) # [] — as expected
mutable_class_attr.py
Types of Methods
class Circle:
_pi = 3.14159265358979 # class attribute (single leading underscore = internal)
def __init__(self, radius):
self.radius = radius
# ── Instance method: operates on self ──
def area(self):
return self._pi * self.radius ** 2
def circumference(self):
return 2 * self._pi * self.radius
# ── Class method: receives cls, not self ──
# Use for alternative constructors or factory patterns
@classmethod
def from_diameter(cls, diameter):
return cls(diameter / 2)
# ── Static method: no self or cls — a utility ──
# Use when the logic belongs conceptually to the class
# but doesn't need access to instance or class state
@staticmethod
def is_valid_radius(value):
return isinstance(value, (int, float)) and value > 0
c1 = Circle(5)
print(c1.area()) # 78.539...
print(c1.circumference()) # 31.415...
c2 = Circle.from_diameter(10) # alternative constructor
print(c2.radius) # 5.0
print(Circle.is_valid_radius(3)) # True
print(Circle.is_valid_radius(-1)) # False
methods.py
| Type | Decorator | First param | Accesses |
|---|---|---|---|
| Instance method | none | self | Instance & class state |
| Class method | @classmethod | cls | Class state only |
| Static method | @staticmethod | none | Neither — utility function |
Properties — Controlled Attribute Access
@property lets you expose a method as if it were a plain attribute,
while keeping control over getting and setting:
class Temperature:
def __init__(self, celsius=0):
self._celsius = celsius # private storage (underscore convention)
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError(f"Temperature below absolute zero: {value}")
self._celsius = value
@celsius.deleter
def celsius(self):
print("Resetting to 0°C")
self._celsius = 0
@property
def fahrenheit(self):
"""Computed property — no setter needed."""
return self._celsius * 9/5 + 32
@property
def kelvin(self):
return self._celsius + 273.15
t = Temperature(100)
print(t.celsius) # 100
print(t.fahrenheit) # 212.0
print(t.kelvin) # 373.15
t.celsius = 0 # calls the setter
print(t.fahrenheit) # 32.0
# t.celsius = -300 # raises ValueError
del t.celsius # calls the deleter → "Resetting to 0°C"
properties.py
Encapsulation & Name Conventions
Python doesn't have strict access modifiers (private, protected)
but uses naming conventions to signal intent:
| Convention | Meaning | Enforcement |
|---|---|---|
name | Public — part of the API | None |
_name | Internal — "don't use outside this class" | Convention only |
__name | Name-mangled — harder to access from outside | Weak (name is mangled to _ClassName__name) |
__name__ | Dunder — special Python method/attribute | Reserved by Python |
class Wallet:
def __init__(self, initial=0):
self._balance = initial # internal — use the property
self.__pin = "1234" # name-mangled
@property
def balance(self):
return self._balance
def _validate_amount(self, amount): # internal helper
if amount <= 0:
raise ValueError("Amount must be positive")
def deposit(self, amount):
self._validate_amount(amount)
self._balance += amount
w = Wallet(100)
print(w.balance) # 100 — via property
# print(w.__pin) # AttributeError — mangled!
print(w._Wallet__pin) # '1234' — mangling, not true privacy
encapsulation.py
Essential Dunder Methods
Special methods (surrounded by double underscores) let your objects integrate with Python's built-in operators and functions:
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
# ── String representations ──
def __repr__(self):
"""Unambiguous — for developers. Shown in REPL."""
return f"Vector({self.x}, {self.y})"
def __str__(self):
"""Readable — for end users. Used by print()."""
return f"({self.x}, {self.y})"
# ── Arithmetic operators ──
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __mul__(self, scalar):
return Vector(self.x * scalar, self.y * scalar)
def __rmul__(self, scalar): # scalar * vector
return self.__mul__(scalar)
# ── Comparison ──
def __eq__(self, other):
return isinstance(other, Vector) and self.x == other.x and self.y == other.y
# ── Length / magnitude ──
def __abs__(self):
return (self.x ** 2 + self.y ** 2) ** 0.5
def __len__(self):
return 2 # a 2-D vector has 2 components
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2) # (4, 6)
print(v1 * 3) # (3, 6)
print(3 * v1) # (3, 6)
print(abs(v2)) # 5.0
print(v1 == Vector(1, 2)) # True
print(repr(v1)) # Vector(1, 2)
dunder_methods.py
| Dunder | Called by |
|---|---|
__init__ | ClassName(…) — constructor |
__repr__ | repr(obj), REPL display |
__str__ | str(obj), print(obj) |
__len__ | len(obj) |
__eq__ | obj == other |
__lt__ | obj < other |
__add__ | obj + other |
__getitem__ | obj[key] |
__contains__ | item in obj |
__iter__ | for item in obj |
__enter__ / __exit__ | with obj |
__call__ | obj(…) — call as function |
Class Design Guidelines
class Rectangle:
"""Represents an axis-aligned rectangle.
Attributes:
width: Width in units (positive float).
height: Height in units (positive float).
"""
def __init__(self, width, height):
self.width = width # goes through the setter
self.height = height
@property
def width(self):
return self._width
@width.setter
def width(self, value):
if value <= 0:
raise ValueError(f"Width must be positive, got {value}")
self._width = value
@property
def height(self):
return self._height
@height.setter
def height(self, value):
if value <= 0:
raise ValueError(f"Height must be positive, got {value}")
self._height = value
@property
def area(self):
return self._width * self._height
@property
def perimeter(self):
return 2 * (self._width + self._height)
def scale(self, factor):
"""Return a new Rectangle scaled by factor."""
return Rectangle(self._width * factor, self._height * factor)
def __repr__(self):
return f"Rectangle(width={self._width}, height={self._height})"
def __eq__(self, other):
return (isinstance(other, Rectangle)
and self._width == other._width
and self._height == other._height)
r = Rectangle(4, 3)
print(r.area) # 12
print(r.perimeter) # 14
print(r.scale(2)) # Rectangle(width=8, height=6)
print(r) # Rectangle(width=4, height=3)
rectangle.py
- One class, one responsibility. Don't build a class that does everything.
- Validate in setters /
__init__. Catch bad data at the boundary. - Always implement
__repr__. It makes debugging vastly easier. - Prefer computed properties over stored data that can become stale.
- Return
selffrom mutating methods only if you want method chaining — otherwise returnNone(Python convention). - Keep
__init__simple. Complex setup belongs in a@classmethodfactory.
A Taste of Dataclasses
For classes that primarily hold data, Python 3.7+ offers
@dataclass — it auto-generates __init__,
__repr__, and __eq__:
from dataclasses import dataclass, field
@dataclass
class Point:
x: float
y: float
def distance_from_origin(self):
return (self.x ** 2 + self.y ** 2) ** 0.5
@dataclass
class Player:
name: str
health: int = 100
inventory: list = field(default_factory=list) # ← safe mutable default
p = Point(3.0, 4.0)
print(p) # Point(x=3.0, y=4.0) — __repr__ for free
print(p.distance_from_origin()) # 5.0
print(p == Point(3.0, 4.0)) # True — __eq__ for free
hero = Player("Alice")
hero.inventory.append("sword")
print(hero) # Player(name='Alice', health=100, inventory=['sword'])
dataclasses.py
@dataclass saves
a lot of boilerplate.
Primary sources: Python Docs — Classes · Python Docs — Special Method Names · Python Docs — dataclasses
Ask your AI tutor! Not sure when to use a class vs a function? Struggling to decide between a property and a method? Want to see how to design a class hierarchy for a real project? OOP design is a great thing to think through together.
💻 Exercises
Implement a Stack class that wraps a list with a clean API:
push(item)— add an item to the toppop()— remove and return the top item; raiseIndexErrorif emptypeek()— return the top item without removing it; raiseIndexErrorif emptyis_empty()— returnTrueif empty__len__— supportlen(stack)__repr__— useful debug representation
Show solution
class Stack:
"""Last-in, first-out (LIFO) data structure."""
def __init__(self):
self._data = []
def push(self, item):
self._data.append(item)
def pop(self):
if self.is_empty():
raise IndexError("pop from empty stack")
return self._data.pop()
def peek(self):
if self.is_empty():
raise IndexError("peek at empty stack")
return self._data[-1]
def is_empty(self):
return len(self._data) == 0
def __len__(self):
return len(self._data)
def __repr__(self):
return f"Stack({self._data!r})"
s = Stack()
s.push(1)
s.push(2)
s.push(3)
print(s) # Stack([1, 2, 3])
print(len(s)) # 3
print(s.peek()) # 3
print(s.pop()) # 3
print(s.pop()) # 2
print(s) # Stack([1])
try:
Stack().pop()
except IndexError as e:
print(e) # pop from empty stack
Design a Product class for an online shop:
- Attributes:
name(str),price(float, must be ≥ 0),stock(int, must be ≥ 0) - Validate both
priceandstockvia property setters buy(quantity=1)— reduce stock; raiseValueErrorif not enough stockrestock(quantity)— increase stocktotal_value— computed property: price × stock__repr__and__str__- A class method
from_dict(data)that creates a Product from a dict
Show solution
class Product:
"""Represents a product in an online shop."""
def __init__(self, name, price, stock=0):
self.name = name
self.price = price # uses setter
self.stock = stock # uses setter
@property
def price(self):
return self._price
@price.setter
def price(self, value):
if value < 0:
raise ValueError(f"Price cannot be negative: {value}")
self._price = float(value)
@property
def stock(self):
return self._stock
@stock.setter
def stock(self, value):
if value < 0:
raise ValueError(f"Stock cannot be negative: {value}")
self._stock = int(value)
@property
def total_value(self):
return self._price * self._stock
def buy(self, quantity=1):
if quantity > self._stock:
raise ValueError(
f"Not enough stock: requested {quantity}, available {self._stock}"
)
self._stock -= quantity
def restock(self, quantity):
if quantity <= 0:
raise ValueError("Restock quantity must be positive")
self._stock += quantity
@classmethod
def from_dict(cls, data):
return cls(data["name"], data["price"], data.get("stock", 0))
def __repr__(self):
return f"Product(name={self.name!r}, price={self._price}, stock={self._stock})"
def __str__(self):
return f"{self.name} — £{self._price:.2f} ({self._stock} in stock)"
# Test
p = Product("Widget", 9.99, 50)
print(p) # Widget — £9.99 (50 in stock)
print(p.total_value) # 499.5
p.buy(5)
print(p.stock) # 45
p.restock(10)
print(p.stock) # 55
p2 = Product.from_dict({"name": "Gadget", "price": 24.99, "stock": 10})
print(p2)
try:
p.buy(1000)
except ValueError as e:
print(e)
Build a Point class representing a 2-D coordinate that:
- Stores
xandyas read-only properties (no setters) - Supports
+,-, and*(scalar) via dunder methods - Supports
==comparison - Implements
__abs__to return the distance from the origin - Implements
__iter__sox, y = pointunpacking works - Implements
__repr__
Show solution
import math
class Point:
"""Immutable 2-D point."""
def __init__(self, x, y):
self._x = x
self._y = y
@property
def x(self):
return self._x
@property
def y(self):
return self._y
def __repr__(self):
return f"Point({self._x}, {self._y})"
def __eq__(self, other):
return isinstance(other, Point) and self._x == other._x and self._y == other._y
def __add__(self, other):
return Point(self._x + other._x, self._y + other._y)
def __sub__(self, other):
return Point(self._x - other._x, self._y - other._y)
def __mul__(self, scalar):
return Point(self._x * scalar, self._y * scalar)
def __rmul__(self, scalar):
return self.__mul__(scalar)
def __abs__(self):
return math.hypot(self._x, self._y)
def __iter__(self):
yield self._x
yield self._y
# Test
a = Point(1, 2)
b = Point(3, 4)
print(a + b) # Point(4, 6)
print(b - a) # Point(2, 2)
print(a * 3) # Point(3, 6)
print(3 * a) # Point(3, 6)
print(abs(b)) # 5.0
print(a == Point(1, 2)) # True
x, y = a # unpacking via __iter__
print(x, y) # 1 2