Object-Oriented Programming
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
namedtupleor@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: