🔵 Intermediate

Classes & OOP Basics

📖 Lesson 20 ⏱ 40 min 🧪 5 questions 💻 3 exercises

🎯 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
A class is a blueprint. An object (or instance) is a specific thing built from that blueprint. You can create as many instances as you like from one class, each with its own independent data.

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
Mutable class attributes are a common trap. If a class attribute is a list or dict, all instances share the same object — mutating it from one instance affects all others. Always initialise mutable attributes in __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
TypeDecoratorFirst paramAccesses
Instance methodnoneselfInstance & class state
Class method@classmethodclsClass state only
Static method@staticmethodnoneNeither — 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
Properties let you start with a plain attribute and add validation or computation later without changing the calling code. This is the Pythonic way to implement encapsulation — not Java-style explicit getters and setters.

Encapsulation & Name Conventions

Python doesn't have strict access modifiers (private, protected) but uses naming conventions to signal intent:

ConventionMeaningEnforcement
namePublic — part of the APINone
_nameInternal — "don't use outside this class"Convention only
__nameName-mangled — harder to access from outsideWeak (name is mangled to _ClassName__name)
__name__Dunder — special Python method/attributeReserved 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
DunderCalled 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 self from mutating methods only if you want method chaining — otherwise return None (Python convention).
  • Keep __init__ simple. Complex setup belongs in a @classmethod factory.

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
Dataclasses are covered fully in Lesson 31. For now, just know they exist — if you're writing a class mostly to store data, @dataclass saves a lot of boilerplate.
🤖

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

01 Stack Data Structure

Implement a Stack class that wraps a list with a clean API:

  • push(item) — add an item to the top
  • pop() — remove and return the top item; raise IndexError if empty
  • peek() — return the top item without removing it; raise IndexError if empty
  • is_empty() — return True if empty
  • __len__ — support len(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
02 Product Class

Design a Product class for an online shop:

  • Attributes: name (str), price (float, must be ≥ 0), stock (int, must be ≥ 0)
  • Validate both price and stock via property setters
  • buy(quantity=1) — reduce stock; raise ValueError if not enough stock
  • restock(quantity) — increase stock
  • total_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)
03 Immutable Point with Operators

Build a Point class representing a 2-D coordinate that:

  • Stores x and y as read-only properties (no setters)
  • Supports +, -, and * (scalar) via dunder methods
  • Supports == comparison
  • Implements __abs__ to return the distance from the origin
  • Implements __iter__ so x, y = point unpacking 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