Object-Oriented Programming lets you model real-world concepts as objects that combine data (attributes) and behaviour (methods). Python's OOP is flexible -- classes are optional, you can mix OOP and functional styles freely. But understanding OOP is essential for reading framework code, writing reusable components, and working in team projects where shared data structures are the norm.
class BankAccount:
interest_rate = 0.05 # Class attribute (shared)
def __init__(self, owner, balance=0):
self.owner = owner # Instance attribute
self.balance = balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Amount must be positive")
self.balance += amount
return self.balance
def __str__(self):
return f"Account[{self.owner}]: Rs.{self.balance:,.2f}"
acc = BankAccount("Suraj", 10000)
acc.deposit(5000)
print(acc) # Account[Suraj]: Rs.15,000.00
print(acc.interest_rate) # 0.05
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
raise NotImplementedError
def __str__(self):
return f"{self.name} ({self.__class__.__name__})"
class Dog(Animal):
def speak(self):
return f"{self.name} says: Woof!"
class Cat(Animal):
def speak(self):
return f"{self.name} says: Meow!"
# Polymorphism
animals = [Dog("Rex"), Cat("Whiskers"), Dog("Max")]
for animal in animals:
print(animal.speak())
class Temperature:
def __init__(self, celsius=0):
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(25)
print(t.fahrenheit) # 77.0
t.celsius = 100
t.celsius = -300 # Raises ValueError
from dataclasses import dataclass, field
from typing import List
@dataclass
class User:
name: str
email: str
age: int
skills: List[str] = field(default_factory=list)
def __post_init__(self):
self.email = self.email.lower()
u = User("Suraj", "SURAJ@EMAIL.COM", 25, ["Python"])
print(u) # User(name='Suraj', email='suraj@email.com', ...)
print(u == User("Suraj", "suraj@email.com", 25, ["Python"])) # True
When you have data and behaviour that naturally belong together and will create multiple instances. User, Product, BankAccount are natural classes. Simple processing functions stay as functions.
The initialiser called when creating an instance. Sets up instance attributes. self refers to the instance being created.
A subclass inherits all methods from a parent class and can add or override them. Call super().__init__() to run the parent's initialiser code.
Special methods like __str__, __repr__, __eq__, __len__ that Python calls automatically. __str__ is called by print(). __eq__ by ==. Implementing these lets objects work naturally with Python built-ins.
@dataclass (Python 3.7+) auto-generates __init__, __repr__, __eq__ for data-holding classes. Saves significant boilerplate. Use frozen=True for immutable instances.
In Part 12, we build a complete real Python project with virtual environment, tests, logging, and automation.
from abc import ABC, abstractmethod
class StorageBackend(ABC):
@abstractmethod
def save(self, key: str, data: bytes) -> bool:
"""Save data to storage. Returns True on success."""
pass
@abstractmethod
def load(self, key: str) -> bytes:
"""Load data from storage. Raises KeyError if not found."""
pass
@abstractmethod
def delete(self, key: str) -> bool:
"""Delete data. Returns True if existed."""
pass
class S3Backend(StorageBackend):
def __init__(self, bucket):
import boto3
self.bucket = bucket
self.s3 = boto3.client("s3")
def save(self, key, data):
self.s3.put_object(Bucket=self.bucket, Key=key, Body=data)
return True
def load(self, key):
response = self.s3.get_object(Bucket=self.bucket, Key=key)
return response["Body"].read()
def delete(self, key):
self.s3.delete_object(Bucket=self.bucket, Key=key)
return True
class LocalBackend(StorageBackend):
def __init__(self, base_dir):
from pathlib import Path
self.base = Path(base_dir)
self.base.mkdir(exist_ok=True)
def save(self, key, data):
(self.base / key).write_bytes(data)
return True
def load(self, key):
return (self.base / key).read_bytes()
def delete(self, key):
(self.base / key).unlink(missing_ok=True)
return True
from typing import Protocol, runtime_checkable
@runtime_checkable
class Drawable(Protocol):
def draw(self) -> str: ...
def get_area(self) -> float: ...
class Circle:
def __init__(self, radius):
self.radius = radius
def draw(self): return f"Circle(r={self.radius})"
def get_area(self): return 3.14159 * self.radius**2
class Square:
def __init__(self, side):
self.side = side
def draw(self): return f"Square(s={self.side})"
def get_area(self): return self.side**2
def render(shape: Drawable):
print(f"Drawing: {shape.draw()}, Area: {shape.get_area():.2f}")
render(Circle(5)) # Works -- Circle has draw() and get_area()
render(Square(4)) # Works -- Square has draw() and get_area()
# Class decorator: add behaviour to all methods
def log_all_methods(cls):
"""Decorator that logs every method call."""
import functools, logging
logger = logging.getLogger(cls.__name__)
for name, method in vars(cls).items():
if callable(method) and not name.startswith("_"):
@functools.wraps(method)
def logged_method(self, *args, _name=name, _method=method, **kwargs):
logger.info(f"Calling {_name}")
result = _method(self, *args, **kwargs)
logger.info(f"{_name} completed")
return result
setattr(cls, name, logged_method)
return cls
@log_all_methods
class PaymentService:
def process_payment(self, amount):
return {"status": "paid", "amount": amount}
def refund(self, transaction_id):
return {"status": "refunded", "id": transaction_id}
from typing import Optional
# Singleton: one instance only
class DatabaseConnection:
_instance: Optional["DatabaseConnection"] = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._connected = False
return cls._instance
def connect(self, url: str):
if not self._connected:
print(f"Connecting to {url}")
self._connected = True
db1 = DatabaseConnection()
db2 = DatabaseConnection()
print(db1 is db2) # True -- same instance
# Observer: notify multiple subscribers
class EventEmitter:
def __init__(self):
self._listeners = {}
def on(self, event: str, callback):
self._listeners.setdefault(event, []).append(callback)
def emit(self, event: str, **kwargs):
for cb in self._listeners.get(event, []):
cb(**kwargs)
emitter = EventEmitter()
emitter.on("order_placed", lambda order_id, amount: print(f"Order {order_id}: Rs.{amount}"))
emitter.on("order_placed", lambda order_id, **_: print(f"Send confirmation for {order_id}"))
emitter.emit("order_placed", order_id=42, amount=999)
class Money:
def __init__(self, amount, currency="INR"):
self.amount = round(float(amount), 2)
self.currency = currency
def __repr__(self):
return f"Money({self.amount}, '{self.currency}')"
def __str__(self):
return f"Rs.{self.amount:,.2f}"
def __add__(self, other):
if self.currency != other.currency:
raise ValueError("Cannot add different currencies")
return Money(self.amount + other.amount, self.currency)
def __mul__(self, factor):
return Money(self.amount * factor, self.currency)
def __eq__(self, other):
return self.amount == other.amount and self.currency == other.currency
def __lt__(self, other):
return self.amount < other.amount
price = Money(999.99)
tax = Money(179.99)
total = price + tax
print(total) # Rs.1,179.98
print(total * 2) # Rs.2,359.96
prices = [Money(500), Money(200), Money(800)]
print(sorted(prices)) # Sorted using __lt__