Prerequisite:

Object-oriented programming (OOP) is a way of organising code around data and the operations on that data. Python supports it fully, but it also makes it optional - which means you can learn when OOP actually helps rather than treating it as the default.

Why OOP: Bundling State and Behaviour

Imagine modelling a bank account. You need data (the balance) and operations (deposit, withdraw, get balance). You could do this with a dict and standalone functions:

account = {"balance": 100}

def deposit(account, amount):
    account["balance"] += amount

This works, but nothing stops someone from setting account["balance"] = -9999 directly. A class lets you keep them together and control access:

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance

    def deposit(self, amount):
        if amount > 0:
            self._balance += amount

    def withdraw(self, amount):
        if 0 < amount <= self._balance:
            self._balance -= amount
        else:
            raise ValueError("Invalid withdrawal amount")

    def get_balance(self):
        return self._balance

Class vs Instance

A class is a blueprint. An instance is an object created from that blueprint:

acc1 = BankAccount("Alice", 500)
acc2 = BankAccount("Bob", 200)

acc1 and acc2 are separate objects with their own _balance. Changing one doesn’t affect the other.

__init__ is the initialiser - it runs when you create an instance. self refers to the specific instance being operated on. Every method gets self as its first argument.

Inheritance and super()

Inheritance lets a class build on another:

class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        raise NotImplementedError

class Dog(Animal):
    def speak(self):
        return f"{self.name} says woof!"

class Cat(Animal):
    def speak(self):
        return f"{self.name} says meow!"

fido = Dog("Fido")
print(fido.speak())   # "Fido says woof!"

super() calls the parent class’s method - useful when you want to extend rather than replace behaviour:

class SavingsAccount(BankAccount):
    def __init__(self, owner, balance=0, interest_rate=0.02):
        super().__init__(owner, balance)   # run BankAccount.__init__
        self.interest_rate = interest_rate

    def apply_interest(self):
        self._balance *= (1 + self.interest_rate)

Polymorphism and Duck Typing

Polymorphism means different objects can respond to the same method name in different ways:

animals = [Dog("Rex"), Cat("Whiskers"), Dog("Buddy")]
for animal in animals:
    print(animal.speak())   # each calls its own speak()

Python takes this further with duck typing: if an object has the right methods, you can use it, regardless of its class. If it walks like a duck and quacks like a duck, it’s a duck. You don’t need explicit inheritance hierarchies.

Encapsulation: Private by Convention

Python doesn’t have strict private access like Java. Instead it uses naming conventions:

  • _name - single underscore: “this is internal, don’t touch it from outside”
  • __name - double underscore: name-mangled to _ClassName__name, making it harder (but not impossible) to access from outside

These are conventions. Python trusts you not to break them.

@property for Controlled Access

Instead of a get_balance() method, you can use @property to make attribute access look natural while keeping control:

class BankAccount:
    def __init__(self, balance=0):
        self._balance = balance

    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError("Balance cannot be negative")
        self._balance = value

acc = BankAccount(100)
print(acc.balance)     # 100 - looks like attribute access
acc.balance = 200      # uses the setter

Magic Methods

Magic methods (also called dunder methods) let your class work with Python’s built-in operators and functions:

class Vector2D:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Vector2D({self.x}, {self.y})"

    def __str__(self):
        return f"({self.x}, {self.y})"

    def __add__(self, other):
        return Vector2D(self.x + other.x, self.y + other.y)

    def __len__(self):
        return 2   # a 2D vector always has 2 components

    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

v1 = Vector2D(1, 2)
v2 = Vector2D(3, 4)
print(v1 + v2)    # (4, 6)   - uses __add__
print(v1 == v2)   # False    - uses __eq__
print(len(v1))    # 2        - uses __len__

__repr__ is for developers (used in the REPL and debugging). __str__ is for users (used by print).

Dataclasses

For classes that are mostly data containers, @dataclass eliminates boilerplate:

from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

p1 = Point(1.0, 2.0)
p2 = Point(1.0, 2.0)
print(p1)         # Point(x=1.0, y=2.0)
print(p1 == p2)   # True - __eq__ generated automatically

@dataclass auto-generates __init__, __repr__, and __eq__. You can add frozen=True to make it immutable.

When Not to Use Classes

A class is the right tool when you have state and behaviour that belong together, or when you’re modelling something that naturally has multiple instances. But many things don’t need classes:

  • A utility function doesn’t need a class.
  • A group of related functions can live in a module.
  • A simple data record can be a namedtuple or @dataclass.

Composition over inheritance is a useful principle: instead of building deep inheritance hierarchies, give objects references to other objects. A Car doesn’t need to inherit from Engine - it has an Engine.

Examples

BankAccount class with validation, properties, and transaction history:

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance
        self._history = []

    @property
    def balance(self):
        return self._balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit must be positive")
        self._balance += amount
        self._history.append(f"Deposit: +{amount}")

    def withdraw(self, amount):
        if amount <= 0 or amount > self._balance:
            raise ValueError("Invalid withdrawal")
        self._balance -= amount
        self._history.append(f"Withdrawal: -{amount}")

    def __repr__(self):
        return f"BankAccount(owner={self.owner!r}, balance={self._balance})"

acc = BankAccount("Alice", 1000)
acc.deposit(500)
acc.withdraw(200)
print(acc)              # BankAccount(owner='Alice', balance=1300)
print(acc._history)     # ['Deposit: +500', 'Withdrawal: -200']

Vector2D with operator overloading (from the magic methods section above) gives you a class where v1 + v2 and v1 == v2 work exactly as you’d expect - without any special-casing in calling code.


Read Next: