Python Full Tutorial -- Part 11: Object Oriented Programming

By Suraj Ahir 2026-01-10 11 min read

← Part 10Python Tutorial · Part 11 of 12Part 12 →
Python Full Tutorial -- Part 11: Object Oriented Programming

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.

Defining Classes

Class with attributes and methods
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

Inheritance

Extending parent classes
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())

Properties for Validation

@property for controlled access
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

Dataclasses

Less boilerplate with @dataclass
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

Frequently Asked Questions

When should I use classes?

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.

What is __init__?

The initialiser called when creating an instance. Sets up instance attributes. self refers to the instance being created.

What is inheritance?

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.

What are dunder methods?

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.

What is a dataclass?

@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.

Key takeaways

Continue reading
Part 12 — From Idea to Deploy
Ship something real.
Suraj Ahir — author of SRJahir Tech

Written by

Suraj Ahir

Cloud & DevOps engineer running four live production services on my own AWS infrastructure. I write everything on this site myself — no ghostwriters, no AI filler.

← Part 10Python Tutorial · Part 11 of 12Part 12 →
← Back to Blog
Disclaimer: Educational content only. No guarantees of outcome.

Abstract Base Classes for Interfaces

Define interfaces in Python
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

Protocol Classes (Structural Subtyping)

Duck typing with type hints
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()

Metaclasses and Class Decorators

Advanced OOP for framework design
# 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}

Design Patterns in Python

Practical patterns used in real codebases
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)

Python Data Model: Dunder Methods

Make custom objects behave like built-ins
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__