What is OOP?
Object-Oriented Programming (OOP) is a programming paradigm that organises code around objects — bundles of related data (attributes) and behaviour (methods). Instead of writing a long sequence of instructions, you model your program as a collection of interacting objects, each responsible for its own piece of logic.
Procedural vs OOP
Before OOP, most programs were written in a procedural style — a series of functions operating on separate data structures. Both approaches work, but they suit different situations.
# --- Procedural Approach ---
def create_student(name, age, grades):
return {"name": name, "age": age, "grades": grades}
def calculate_average(student):
if not student["grades"]:
return 0
return sum(student["grades"]) / len(student["grades"])
def is_passing(student, threshold=50):
return calculate_average(student) >= threshold
# Usage
student1 = create_student("Priya", 22, [85, 90, 78])
student2 = create_student("Rahul", 24, [45, 38, 52])
print(calculate_average(student1)) # 84.33
print(is_passing(student2)) # False
# --- OOP Approach ---
class Student:
def __init__(self, name, age, grades=None):
self.name = name
self.age = age
self.grades = grades if grades is not None else []
def calculate_average(self):
if not self.grades:
return 0
return sum(self.grades) / len(self.grades)
def is_passing(self, threshold=50):
return self.calculate_average() >= threshold
# Usage
student1 = Student("Priya", 22, [85, 90, 78])
student2 = Student("Rahul", 24, [45, 38, 52])
print(student1.calculate_average()) # 84.33
print(student2.is_passing()) # False
In the procedural version, data and functions live separately — any function can modify the dictionary in unexpected ways. In the OOP version, each Student object owns its data and the methods that operate on it, keeping everything tidy and self-contained.
| Aspect | Procedural | OOP |
|---|---|---|
| Organisation | Functions + separate data | Objects bundle data + behaviour |
| Data safety | Any function can modify any data | Objects protect their own data |
| Reusability | Copy-paste functions | Inherit and extend classes |
| Modelling | Abstract sequences of steps | Models real-world entities |
| Best for | Small scripts, quick automation | Large applications, team projects |
The Four Pillars of OOP
OOP rests on four core principles. We will explore each one in detail throughout this chapter.
-
Encapsulation — Bundling data and methods together, and restricting direct access to some of an object's internals. Think of a TV remote: you press buttons (public interface) without knowing the circuit board inside (hidden details).
-
Abstraction — Hiding complex implementation details and exposing only the essential features. A car's steering wheel abstracts away the rack-and-pinion mechanism underneath.
-
Inheritance — Creating new classes based on existing ones, inheriting their attributes and methods. Just as a "Golden Retriever" inherits traits from the general category "Dog", a child class inherits from a parent class.
-
Polymorphism — The ability of different objects to respond to the same method call in their own way. When you call
.area()on aCircleand aRectangle, each computes the area using its own formula.
Real-World Analogy
Think of a class as an architectural blueprint for a house. The blueprint specifies how many rooms there are, where the doors go, and what materials to use. An object is an actual house built from that blueprint. You can build many houses from the same blueprint — each house is a separate object with its own paint colour and furniture, but they all share the same structure.
Classes and Objects
Defining a Class
A class is defined using the class keyword. By convention, class names use PascalCase (each word capitalised, no underscores).
class Dog:
pass # An empty class — valid but does nothing yet
Even this minimal class can create objects:
d1 = Dog()
d2 = Dog()
print(type(d1)) # <class '__main__.Dog'>
print(d1 is d2) # False — two distinct objects
print(isinstance(d1, Dog)) # True
The __init__ Constructor and self
Almost every class defines an __init__ method — the constructor. Python calls it automatically whenever you create a new object.
class Dog:
def __init__(self, name, breed, age):
self.name = name # Assign parameter to instance attribute
self.breed = breed
self.age = age
buddy = Dog("Buddy", "Golden Retriever", 3)
print(buddy.name) # Buddy
print(buddy.breed) # Golden Retriever
print(buddy.age) # 3
What is self?
self is a reference to the current instance — the specific object being created or acted upon. When you write self.name = name, you are saying "store the value of name as an attribute on this particular object."
Python passes self automatically — you never pass it explicitly when calling a method:
class Dog:
def __init__(self, name):
self.name = name
def bark(self):
return f"{self.name} says Woof!"
buddy = Dog("Buddy")
# Python translates buddy.bark() into Dog.bark(buddy) behind the scenes
print(buddy.bark()) # Buddy says Woof!
print(Dog.bark(buddy)) # Buddy says Woof! (same thing, explicit form)
Note:
selfis just a convention — you could technically name it anything — but usingselfis a universal Python convention. Never change it.
Creating Multiple Objects
Each object is independent. Modifying one does not affect the others.
class Student:
def __init__(self, name, grade):
self.name = name
self.grade = grade
def promote(self):
self.grade += 1
return f"{self.name} promoted to grade {self.grade}"
s1 = Student("Priya", 10)
s2 = Student("Rahul", 10)
print(s1.promote()) # Priya promoted to grade 11
print(s2.grade) # 10 — s2 is unaffected
Instance Attributes vs Class Attributes
Python has two types of attributes: those belonging to the class (shared by all instances) and those belonging to each instance (unique per object).
Class Attributes
Defined directly inside the class body, outside any method. They are shared across every object of that class.
class Car:
# Class attribute — shared by all Cars
wheels = 4
count = 0
def __init__(self, brand, model):
# Instance attributes — unique to each Car
self.brand = brand
self.model = model
Car.count += 1 # Update the shared counter
c1 = Car("Toyota", "Camry")
c2 = Car("Honda", "Civic")
c3 = Car("Tata", "Nexon")
print(Car.count) # 3
print(c1.wheels) # 4 (looked up from the class)
print(c2.wheels) # 4
print(Car.wheels) # 4
How Attribute Lookup Works
When you access c1.wheels, Python first checks c1's own instance dictionary. If it does not find wheels there, it looks in the class dictionary. This is why all instances appear to "share" the class attribute — they are all reading it from the same place.
print(c1.__dict__) # {'brand': 'Toyota', 'model': 'Camry'} — no 'wheels'
print(Car.__dict__) # {'wheels': 4, 'count': 3, '__init__': ..., ...}
The Mutable Class Attribute Pitfall
Be careful with mutable class attributes (lists, dictionaries). All instances share the same object, so mutating it from one instance affects every other instance.
# WRONG — shared mutable list
class StudentBad:
grades = [] # This list is shared!
def __init__(self, name):
self.name = name
def add_grade(self, grade):
self.grades.append(grade) # Mutates the shared list
s1 = StudentBad("Priya")
s2 = StudentBad("Rahul")
s1.add_grade(95)
s2.add_grade(78)
print(s1.grades) # [95, 78] — s1 sees s2's grade too!
print(s2.grades) # [95, 78] — same list object
# CORRECT — each instance gets its own list
class StudentGood:
def __init__(self, name):
self.name = name
self.grades = [] # Instance attribute — unique per object
def add_grade(self, grade):
self.grades.append(grade)
s1 = StudentGood("Priya")
s2 = StudentGood("Rahul")
s1.add_grade(95)
s2.add_grade(78)
print(s1.grades) # [95]
print(s2.grades) # [78] — separate lists
Comparison Table
| Feature | Instance Attribute | Class Attribute |
|---|---|---|
| Defined in | __init__ (via self.x = ...) | Class body (outside methods) |
| Belongs to | A single object | The class itself (shared) |
| Accessed via | self.x or obj.x | ClassName.x or obj.x |
| Common use | Object-specific data (name, id) | Constants, counters, defaults |
| Mutable pitfall | No issue | Shared mutable objects are dangerous |
Methods
Methods are functions defined inside a class. They define the behaviour of objects.
Instance Methods
The most common type. They take self as the first parameter and can read/modify instance attributes.
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
def perimeter(self):
return 2 * (self.width + self.height)
def is_square(self):
return self.width == self.height
def scale(self, factor):
self.width *= factor
self.height *= factor
r = Rectangle(5, 3)
print(r.area()) # 15
print(r.perimeter()) # 16
print(r.is_square()) # False
r.scale(2)
print(r.area()) # 60 (10 * 6)
Method Chaining
If a method returns self, you can chain multiple method calls on a single line. This is a common pattern in builder-style APIs.
class QueryBuilder:
def __init__(self, table):
self.table = table
self._columns = "*"
self._where = ""
self._order = ""
self._limit = ""
def select(self, columns):
self._columns = columns
return self # Return self to enable chaining
def where(self, condition):
self._where = f" WHERE {condition}"
return self
def order_by(self, column, direction="ASC"):
self._order = f" ORDER BY {column} {direction}"
return self
def limit(self, n):
self._limit = f" LIMIT {n}"
return self
def build(self):
return f"SELECT {self._columns} FROM {self.table}{self._where}{self._order}{self._limit}"
# Method chaining in action
query = (
QueryBuilder("students")
.select("name, grade")
.where("grade > 80")
.order_by("grade", "DESC")
.limit(10)
.build()
)
print(query)
# SELECT name, grade FROM students WHERE grade > 80 ORDER BY grade DESC LIMIT 10
self in Depth
A common source of confusion: self is not a keyword — it is simply the first parameter that receives the instance. You must include it in every instance method signature.
class Counter:
def __init__(self):
self.value = 0
def increment(self):
self.value += 1
def decrement(self):
self.value -= 1
def reset(self):
self.value = 0
def get(self):
return self.value
c = Counter()
c.increment()
c.increment()
c.increment()
c.decrement()
print(c.get()) # 2
Every time you call c.increment(), Python passes c as self behind the scenes.
The __init__ Method
Constructor Patterns
The __init__ method is where you set up the initial state of an object. It is called automatically after the object is created in memory.
class Product:
def __init__(self, name, price, quantity=0):
self.name = name
self.price = price
self.quantity = quantity
def total_value(self):
return self.price * self.quantity
# With default quantity
p1 = Product("Laptop", 75000)
print(p1.quantity) # 0
# With explicit quantity
p2 = Product("Mouse", 500, 50)
print(p2.total_value()) # 25000
Validation in __init__
You should validate data at construction time to prevent invalid objects from existing.
class Age:
def __init__(self, years):
if not isinstance(years, int):
raise TypeError("Age must be an integer")
if years < 0 or years > 150:
raise ValueError("Age must be between 0 and 150")
self.years = years
# Valid
a = Age(25)
print(a.years) # 25
# Invalid — raises ValueError
try:
b = Age(-5)
except ValueError as e:
print(e) # Age must be between 0 and 150
# Invalid — raises TypeError
try:
c = Age("twenty")
except TypeError as e:
print(e) # Age must be an integer
Calling Parent __init__
When a child class has its own __init__, the parent's __init__ is not called automatically. You must call it explicitly using super().
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
class Employee(Person):
def __init__(self, name, age, employee_id, department):
super().__init__(name, age) # Call parent's __init__
self.employee_id = employee_id
self.department = department
e = Employee("Priya", 30, "E001", "Engineering")
print(e.name) # Priya (from Person.__init__)
print(e.employee_id) # E001 (from Employee.__init__)
print(e.department) # Engineering
If you forget super().__init__(name, age), the Employee object will not have name or age attributes, and accessing them will raise an AttributeError.
Encapsulation
Encapsulation means bundling data and methods together and controlling access to the internal state of an object. Python does not enforce strict access control like Java or C++, but it uses naming conventions to communicate intent.
Public, Protected, and Private
| Convention | Example | Meaning |
|---|---|---|
name | self.name | Public — accessible from anywhere |
_name | self._name | Protected — "internal use; access with caution" (convention only) |
__name | self.__name | Private — name-mangled; harder to access from outside |
class Employee:
def __init__(self, name, salary, ssn):
self.name = name # Public
self._salary = salary # Protected (convention)
self.__ssn = ssn # Private (name-mangled)
e = Employee("Priya", 80000, "123-45-6789")
# Public — accessible
print(e.name) # Priya
# Protected — accessible, but signals "internal"
print(e._salary) # 80000
# Private — raises AttributeError
try:
print(e.__ssn)
except AttributeError as err:
print(err) # 'Employee' object has no attribute '__ssn'
Name Mangling
When you use __name (double underscore prefix), Python internally renames it to _ClassName__name. This is called name mangling. It is not true security — it is a mechanism to avoid accidental name clashes in subclasses.
# You CAN still access it, but you shouldn't
print(e._Employee__ssn) # 123-45-6789
# Inspect the object's dictionary to see the mangled name
print(e.__dict__)
# {'name': 'Priya', '_salary': 80000, '_Employee__ssn': '123-45-6789'}
Property Decorators — The Pythonic Way
Instead of writing explicit get_balance() and set_balance() methods (Java-style), Python uses the @property decorator to create clean attribute-like access with validation.
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self._balance = balance # "Protected" backing attribute
@property
def balance(self):
"""Getter — called when you read account.balance"""
return self._balance
@balance.setter
def balance(self, value):
"""Setter — called when you write account.balance = x"""
if value < 0:
raise ValueError("Balance cannot be negative")
self._balance = value
@balance.deleter
def balance(self):
"""Deleter — called when you do del account.balance"""
print("Closing account...")
self._balance = 0
# Usage looks like normal attribute access
acc = BankAccount("Priya", 1000)
print(acc.balance) # 1000 (calls getter)
acc.balance = 2000 # Calls setter
print(acc.balance) # 2000
# Validation works
try:
acc.balance = -500
except ValueError as e:
print(e) # Balance cannot be negative
# Deleter
del acc.balance # Closing account...
print(acc.balance) # 0
A More Complete Property Example
class Temperature:
def __init__(self, celsius=0):
self.celsius = celsius # This calls the setter!
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Temperature below absolute zero is not possible")
self._celsius = value
@property
def fahrenheit(self):
return (self._celsius * 9 / 5) + 32
@fahrenheit.setter
def fahrenheit(self, value):
self.celsius = (value - 32) * 5 / 9 # Delegates to celsius setter
@property
def kelvin(self):
return self._celsius + 273.15
@kelvin.setter
def kelvin(self, value):
self.celsius = value - 273.15
temp = Temperature(100)
print(f"{temp.celsius}°C = {temp.fahrenheit}°F = {temp.kelvin}K")
# 100°C = 212.0°F = 373.15K
temp.fahrenheit = 72
print(f"{temp.celsius:.2f}°C") # 22.22°C
temp.kelvin = 0
print(f"{temp.celsius}°C") # -273.15°C
Inheritance
Inheritance lets you create a child class (subclass) that inherits attributes and methods from a parent class (superclass). This promotes code reuse and models "is-a" relationships.
Single Inheritance
class Animal:
def __init__(self, name, species):
self.name = name
self.species = species
def speak(self):
return f"{self.name} makes a sound"
def info(self):
return f"{self.name} is a {self.species}"
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name, "Dog") # Call parent constructor
self.breed = breed
def speak(self): # Override parent method
return f"{self.name} says Woof!"
def fetch(self): # New method, only in Dog
return f"{self.name} fetches the ball!"
class Cat(Animal):
def __init__(self, name, color):
super().__init__(name, "Cat")
self.color = color
def speak(self):
return f"{self.name} says Meow!"
# Usage
dog = Dog("Buddy", "Golden Retriever")
cat = Cat("Whiskers", "orange")
print(dog.speak()) # Buddy says Woof!
print(dog.info()) # Buddy is a Dog (inherited from Animal)
print(dog.fetch()) # Buddy fetches the ball!
print(cat.speak()) # Whiskers says Meow!
super() Explained
super() returns a proxy object that delegates method calls to the parent class. It is most commonly used in __init__, but it works with any method.
class Shape:
def __init__(self, color="black"):
self.color = color
def describe(self):
return f"A {self.color} shape"
class Circle(Shape):
def __init__(self, radius, color="black"):
super().__init__(color) # Set color via parent
self.radius = radius
def describe(self):
# Extend (not just replace) the parent's method
base = super().describe()
return f"{base} — circle with radius {self.radius}"
c = Circle(5, "red")
print(c.describe()) # A red shape — circle with radius 5
Method Overriding
When a child class defines a method with the same name as the parent, it overrides the parent's version. The child's version is called instead.
class Vehicle:
def fuel_type(self):
return "Petrol"
class ElectricCar(Vehicle):
def fuel_type(self): # Overrides Vehicle.fuel_type
return "Electric"
class HybridCar(Vehicle):
def fuel_type(self):
return "Petrol + Electric"
v = Vehicle()
e = ElectricCar()
h = HybridCar()
print(v.fuel_type()) # Petrol
print(e.fuel_type()) # Electric
print(h.fuel_type()) # Petrol + Electric
isinstance() and issubclass()
These built-in functions check object types and class relationships.
class Animal:
pass
class Dog(Animal):
pass
class Cat(Animal):
pass
d = Dog()
# isinstance checks if an object is an instance of a class (or its parents)
print(isinstance(d, Dog)) # True
print(isinstance(d, Animal)) # True — Dog IS an Animal
print(isinstance(d, Cat)) # False
# issubclass checks class relationships
print(issubclass(Dog, Animal)) # True
print(issubclass(Dog, Cat)) # False
print(issubclass(Animal, object)) # True — everything inherits from object
What Gets Inherited
A child class inherits all public and protected attributes and methods. Private (name-mangled) attributes are technically inherited but are harder to access due to mangling.
class Parent:
class_var = "I am shared"
def __init__(self):
self.public = "Accessible"
self._protected = "Use carefully"
self.__private = "Mangled"
def public_method(self):
return "Parent public method"
def _protected_method(self):
return "Parent protected method"
def __private_method(self):
return "Parent private method"
class Child(Parent):
pass
c = Child()
print(c.public) # Accessible
print(c._protected) # Use carefully
print(c.public_method()) # Parent public method
print(c._protected_method()) # Parent protected method
print(c.class_var) # I am shared
# Private — name-mangled under PARENT's name
print(c._Parent__private) # Mangled
print(c._Parent__private_method()) # Parent private method
Method Resolution Order (MRO)
When Python looks up a method, it searches in a specific order: the class itself first, then its parents, left to right. You can inspect this order using ClassName.__mro__ or ClassName.mro().
class A:
def greet(self):
return "Hello from A"
class B(A):
def greet(self):
return "Hello from B"
class C(A):
def greet(self):
return "Hello from C"
class D(B, C):
pass
d = D()
print(d.greet()) # Hello from B
# See the full resolution order
print(D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)
Python searches D -> B -> C -> A -> object. Since B has greet(), that is the one used.
Multiple Inheritance
Python supports inheriting from more than one parent class.
Basic Syntax
class Flyable:
def fly(self):
return f"{self.name} is flying"
class Swimmable:
def swim(self):
return f"{self.name} is swimming"
class Duck(Flyable, Swimmable):
def __init__(self, name):
self.name = name
def quack(self):
return f"{self.name} says Quack!"
d = Duck("Donald")
print(d.fly()) # Donald is flying
print(d.swim()) # Donald is swimming
print(d.quack()) # Donald says Quack!
The Diamond Problem and C3 Linearization
When two parent classes share a common grandparent, which version of a method should be called? This is the diamond problem. Python solves it with C3 linearization — a deterministic algorithm that computes the MRO.
class A:
def who_am_i(self):
return "A"
class B(A):
def who_am_i(self):
return "B"
class C(A):
def who_am_i(self):
return "C"
class D(B, C): # Diamond: D -> B -> C -> A
pass
d = D()
print(d.who_am_i()) # B (B comes before C in the MRO)
print(D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)
super() in Multiple Inheritance
With multiple inheritance, super() follows the MRO — it does not always call the immediate parent. This enables cooperative multiple inheritance.
class A:
def __init__(self):
print("A.__init__")
super().__init__()
class B(A):
def __init__(self):
print("B.__init__")
super().__init__()
class C(A):
def __init__(self):
print("C.__init__")
super().__init__()
class D(B, C):
def __init__(self):
print("D.__init__")
super().__init__()
d = D()
# Output (follows MRO: D -> B -> C -> A):
# D.__init__
# B.__init__
# C.__init__
# A.__init__
Notice that super() in B.__init__ calls C.__init__ (not A.__init__), because C is next in the MRO of D.
Mixins
A mixin is a class that provides specific functionality meant to be combined with other classes. Mixins are not meant to stand alone.
class JsonMixin:
"""Mixin that adds JSON serialisation to any class with a __dict__."""
def to_json(self):
import json
return json.dumps(self.__dict__, indent=2, default=str)
class LogMixin:
"""Mixin that adds logging capability."""
def log(self, message):
print(f"[{self.__class__.__name__}] {message}")
class User(JsonMixin, LogMixin):
def __init__(self, name, email):
self.name = name
self.email = email
class Product(JsonMixin, LogMixin):
def __init__(self, title, price):
self.title = title
self.price = price
# User gets both mixins
u = User("Priya", "priya@example.com")
print(u.to_json())
# {
# "name": "Priya",
# "email": "priya@example.com"
# }
u.log("Profile updated") # [User] Profile updated
# Product also gets both mixins
p = Product("Laptop", 75000)
print(p.to_json())
# {
# "title": "Laptop",
# "price": 75000
# }
p.log("Price changed") # [Product] Price changed
Polymorphism
Polymorphism means "many forms" — the ability of different objects to respond to the same method call in their own way.
Method Overriding as Polymorphism
class Shape:
def area(self):
return 0
def describe(self):
return f"Shape with area {self.area():.2f}"
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
import math
return math.pi * self.radius ** 2
class Triangle(Shape):
def __init__(self, base, height):
self.base = base
self.height = height
def area(self):
return 0.5 * self.base * self.height
# Polymorphism in action — same method name, different behaviour
shapes = [Rectangle(5, 3), Circle(4), Triangle(6, 8)]
for shape in shapes:
print(shape.describe())
# Output:
# Shape with area 15.00
# Shape with area 50.27
# Shape with area 24.00
Duck Typing
Python follows the principle: "If it walks like a duck and quacks like a duck, it is a duck." You do not need to inherit from a common class — if an object has the right method, it works.
class Duck:
def sound(self):
return "Quack!"
class Person:
def sound(self):
return "I'm quacking like a duck!"
class Radio:
def sound(self):
return "♪ Music playing ♪"
# No shared parent class, no interface — just the same method name
def make_sound(thing):
print(thing.sound())
make_sound(Duck()) # Quack!
make_sound(Person()) # I'm quacking like a duck!
make_sound(Radio()) # ♪ Music playing ♪
Duck typing is fundamental to Python. Built-in functions like len(), iter(), and str() all rely on duck typing — they call the corresponding dunder method on whatever object you pass.
# len() works on anything that has __len__
class Playlist:
def __init__(self, songs):
self.songs = songs
def __len__(self):
return len(self.songs)
p = Playlist(["Song A", "Song B", "Song C"])
print(len(p)) # 3
Operator Overloading Basics
Python lets you define how operators (+, -, ==, <, etc.) work on your custom objects by implementing dunder methods.
class Money:
def __init__(self, amount, currency="INR"):
self.amount = amount
self.currency = currency
def __add__(self, other):
if self.currency != other.currency:
raise ValueError("Cannot add different currencies")
return Money(self.amount + other.amount, self.currency)
def __sub__(self, other):
if self.currency != other.currency:
raise ValueError("Cannot subtract different currencies")
return Money(self.amount - other.amount, self.currency)
def __eq__(self, other):
return self.amount == other.amount and self.currency == other.currency
def __lt__(self, other):
if self.currency != other.currency:
raise ValueError("Cannot compare different currencies")
return self.amount < other.amount
def __str__(self):
return f"{self.currency} {self.amount:,.2f}"
a = Money(1000)
b = Money(500)
print(a + b) # INR 1,500.00
print(a - b) # INR 500.00
print(a == b) # False
print(b < a) # True
Polymorphism with Functions
You can write functions that work with any object implementing the expected interface.
class CSVExporter:
def export(self, data):
lines = [",".join(data[0].keys())]
for row in data:
lines.append(",".join(str(v) for v in row.values()))
return "\n".join(lines)
class JSONExporter:
def export(self, data):
import json
return json.dumps(data, indent=2)
class MarkdownExporter:
def export(self, data):
if not data:
return ""
headers = list(data[0].keys())
lines = ["| " + " | ".join(headers) + " |"]
lines.append("| " + " | ".join("---" for _ in headers) + " |")
for row in data:
lines.append("| " + " | ".join(str(v) for v in row.values()) + " |")
return "\n".join(lines)
# Polymorphic function — works with any exporter
def generate_report(exporter, data):
print(exporter.export(data))
data = [
{"name": "Priya", "score": 95},
{"name": "Rahul", "score": 82},
]
generate_report(CSVExporter(), data)
generate_report(JSONExporter(), data)
generate_report(MarkdownExporter(), data)
Abstraction
Abstraction means hiding complex details and exposing only the essential interface. In Python, you implement this with Abstract Base Classes (ABCs).
Abstract Base Classes
The abc module lets you define classes that cannot be instantiated directly and that force subclasses to implement certain methods.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
"""Subclasses MUST implement this method."""
pass
@abstractmethod
def perimeter(self):
"""Subclasses MUST implement this method."""
pass
def describe(self):
"""Concrete method — inherited as-is."""
return f"{self.__class__.__name__}: area={self.area():.2f}, perimeter={self.perimeter():.2f}"
# Cannot instantiate an abstract class
try:
s = Shape()
except TypeError as e:
print(e)
# Can't instantiate abstract class Shape with abstract methods area, perimeter
Implementing the Abstract Class
import math
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return math.pi * self.radius ** 2
def perimeter(self):
return 2 * math.pi * self.radius
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
def perimeter(self):
return 2 * (self.width + self.height)
class Triangle(Shape):
def __init__(self, a, b, c):
self.a = a
self.b = b
self.c = c
def area(self):
# Heron's formula
s = (self.a + self.b + self.c) / 2
return math.sqrt(s * (s - self.a) * (s - self.b) * (s - self.c))
def perimeter(self):
return self.a + self.b + self.c
# If you forget to implement an abstract method, Python raises TypeError
class BadShape(Shape):
def area(self):
return 0
# Missing perimeter()!
try:
bs = BadShape()
except TypeError as e:
print(e)
# Can't instantiate abstract class BadShape with abstract method perimeter
Why Use ABCs?
- Enforce a contract: Guarantee that every subclass implements required methods.
- Clear interfaces: Document what any "Shape" must be able to do.
- Catch errors early: Errors at instantiation time, not when you call the missing method later.
# Using polymorphism with ABCs
shapes = [Circle(5), Rectangle(4, 6), Triangle(3, 4, 5)]
for shape in shapes:
print(shape.describe())
# Output:
# Circle: area=78.54, perimeter=31.42
# Rectangle: area=24.00, perimeter=20.00
# Triangle: area=6.00, perimeter=12.00
Magic/Dunder Methods
Dunder (double underscore) methods let you define how your objects interact with Python's built-in functions and operators. They are the key to making your classes feel native and Pythonic.
__str__ and __repr__
__str__— User-friendly string (forprint()andstr())__repr__— Developer-friendly string (for debugging,repr(), interactive console)
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return f"({self.x}, {self.y})"
def __repr__(self):
return f"Point(x={self.x}, y={self.y})"
p = Point(3, 7)
print(p) # (3, 7) — calls __str__
print(repr(p)) # Point(x=3, y=7) — calls __repr__
print([p]) # [Point(x=3, y=7)] — lists use __repr__ for elements
Rule of thumb:
__repr__should ideally return a string that could recreate the object.__str__should return something a user would want to read. If you only implement one, implement__repr__— Python falls back to it when__str__is missing.
__len__, __getitem__, __setitem__, __contains__
These let your object behave like a collection.
class Deck:
"""A deck of playing cards."""
SUITS = ["Hearts", "Diamonds", "Clubs", "Spades"]
RANKS = ["2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A"]
def __init__(self):
self.cards = [
f"{rank} of {suit}"
for suit in self.SUITS
for rank in self.RANKS
]
def __len__(self):
return len(self.cards)
def __getitem__(self, index):
return self.cards[index]
def __setitem__(self, index, value):
self.cards[index] = value
def __contains__(self, card):
return card in self.cards
def __repr__(self):
return f"Deck({len(self)} cards)"
deck = Deck()
print(len(deck)) # 52
print(deck[0]) # 2 of Hearts
print(deck[-1]) # A of Spades
print("A of Spades" in deck) # True
# Slicing works too (because __getitem__ handles it)
print(deck[:3]) # ['2 of Hearts', '3 of Hearts', '4 of Hearts']
Comparison Methods: __eq__, __lt__, and Others
class Student:
def __init__(self, name, gpa):
self.name = name
self.gpa = gpa
def __eq__(self, other):
if not isinstance(other, Student):
return NotImplemented
return self.gpa == other.gpa
def __lt__(self, other):
if not isinstance(other, Student):
return NotImplemented
return self.gpa < other.gpa
def __le__(self, other):
return self == other or self < other
def __gt__(self, other):
if not isinstance(other, Student):
return NotImplemented
return self.gpa > other.gpa
def __ge__(self, other):
return self == other or self > other
def __repr__(self):
return f"Student('{self.name}', gpa={self.gpa})"
students = [
Student("Priya", 3.9),
Student("Rahul", 3.5),
Student("Anita", 3.9),
Student("Vijay", 3.7),
]
# Sorting uses __lt__
students.sort()
print(students)
# [Student('Rahul', gpa=3.5), Student('Vijay', gpa=3.7), Student('Priya', gpa=3.9), Student('Anita', gpa=3.9)]
print(students[0] < students[-1]) # True
print(students[2] == students[3]) # True (same GPA)
Tip: You can implement just
__eq__and__lt__, then use@functools.total_orderingto auto-generate the rest.
Arithmetic Operators: __add__, __sub__, __mul__
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __sub__(self, other):
return Vector(self.x - other.x, self.y - other.y)
def __mul__(self, scalar):
if isinstance(scalar, (int, float)):
return Vector(self.x * scalar, self.y * scalar)
return NotImplemented
def __rmul__(self, scalar):
return self.__mul__(scalar) # Support 3 * vector
def __abs__(self):
return (self.x ** 2 + self.y ** 2) ** 0.5
def __eq__(self, other):
return self.x == other.x and self.y == other.y
def __repr__(self):
return f"Vector({self.x}, {self.y})"
v1 = Vector(2, 3)
v2 = Vector(4, 1)
print(v1 + v2) # Vector(6, 4)
print(v1 - v2) # Vector(-2, 2)
print(v1 * 3) # Vector(6, 9)
print(3 * v1) # Vector(6, 9) — uses __rmul__
print(abs(v1)) # 3.605551275...
__iter__ and __next__ — Making Iterable Objects
class Countdown:
def __init__(self, start):
self.start = start
def __iter__(self):
self.current = self.start
return self
def __next__(self):
if self.current < 0:
raise StopIteration
value = self.current
self.current -= 1
return value
# Use in a for loop
for num in Countdown(5):
print(num, end=" ")
# Output: 5 4 3 2 1 0
# Or convert to list
print(list(Countdown(3))) # [3, 2, 1, 0]
__call__ — Making Objects Callable
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, value):
return value * self.factor
double = Multiplier(2)
triple = Multiplier(3)
print(double(10)) # 20
print(triple(10)) # 30
# Callable check
print(callable(double)) # True
__enter__ and __exit__ — Context Managers
These dunder methods let you use your class with the with statement.
class FileManager:
def __init__(self, filename, mode="r"):
self.filename = filename
self.mode = mode
self.file = None
def __enter__(self):
print(f"Opening {self.filename}")
self.file = open(self.filename, self.mode)
return self.file
def __exit__(self, exc_type, exc_val, exc_tb):
print(f"Closing {self.filename}")
if self.file:
self.file.close()
return False # Do not suppress exceptions
# Usage
# with FileManager("test.txt", "w") as f:
# f.write("Hello, World!")
# Output:
# Opening test.txt
# Closing test.txt
A practical example — timing a block of code:
import time
class Timer:
def __enter__(self):
self.start = time.time()
return self
def __exit__(self, *args):
self.elapsed = time.time() - self.start
print(f"Elapsed: {self.elapsed:.4f} seconds")
with Timer():
total = sum(range(1_000_000))
# Elapsed: 0.0312 seconds (varies)
Table of Common Dunder Methods
| Method | Triggered By | Purpose |
|---|---|---|
__init__(self, ...) | MyClass() | Initialise a new object |
__str__(self) | str(obj), print(obj) | User-friendly string |
__repr__(self) | repr(obj) | Developer-friendly string |
__len__(self) | len(obj) | Return length |
__getitem__(self, key) | obj[key] | Index/key access |
__setitem__(self, key, val) | obj[key] = val | Index/key assignment |
__delitem__(self, key) | del obj[key] | Delete item |
__contains__(self, item) | item in obj | Membership test |
__iter__(self) | for x in obj | Return iterator |
__next__(self) | next(obj) | Next item in iteration |
__call__(self, ...) | obj() | Make object callable |
__eq__(self, other) | obj == other | Equality |
__lt__(self, other) | obj < other | Less than |
__le__(self, other) | obj <= other | Less than or equal |
__gt__(self, other) | obj > other | Greater than |
__ge__(self, other) | obj >= other | Greater than or equal |
__ne__(self, other) | obj != other | Not equal |
__add__(self, other) | obj + other | Addition |
__sub__(self, other) | obj - other | Subtraction |
__mul__(self, other) | obj * other | Multiplication |
__truediv__(self, other) | obj / other | Division |
__floordiv__(self, other) | obj // other | Floor division |
__mod__(self, other) | obj % other | Modulo |
__pow__(self, other) | obj ** other | Power |
__abs__(self) | abs(obj) | Absolute value |
__bool__(self) | bool(obj), if obj: | Truthiness |
__hash__(self) | hash(obj) | Hash (for dicts/sets) |
__enter__/__exit__ | with obj: | Context manager |
Class Methods and Static Methods
Python has three types of methods: instance methods, class methods, and static methods.
Instance Methods (Recap)
Take self as the first parameter. They can access and modify both instance and class state.
class Dog:
species = "Canis familiaris"
def __init__(self, name, age):
self.name = name
self.age = age
def description(self): # Instance method
return f"{self.name} is {self.age} years old"
@classmethod — Works with the Class
Takes cls (the class itself) as the first parameter. Cannot access instance attributes, but can access/modify class attributes. Commonly used as factory methods — alternative constructors.
class Employee:
raise_rate = 1.05
employee_count = 0
def __init__(self, name, salary):
self.name = name
self.salary = salary
Employee.employee_count += 1
@classmethod
def set_raise_rate(cls, rate):
"""Modify a class attribute."""
cls.raise_rate = rate
@classmethod
def from_string(cls, emp_string):
"""Factory method — create Employee from a dash-separated string."""
name, salary = emp_string.split("-")
return cls(name, int(salary))
@classmethod
def get_count(cls):
return cls.employee_count
# Using the factory method
e1 = Employee("Priya", 80000)
e2 = Employee.from_string("Rahul-75000") # Alternative constructor
print(e1.name) # Priya
print(e2.name) # Rahul
print(e2.salary) # 75000
Employee.set_raise_rate(1.10)
print(Employee.raise_rate) # 1.1
print(Employee.get_count()) # 2
@staticmethod — Utility Function
Takes neither self nor cls. It is just a regular function that lives inside the class for organisational purposes. It cannot access or modify class or instance state.
class MathUtils:
@staticmethod
def is_even(n):
return n % 2 == 0
@staticmethod
def factorial(n):
if n < 0:
raise ValueError("Factorial undefined for negative numbers")
result = 1
for i in range(2, n + 1):
result *= i
return result
@staticmethod
def fibonacci(n):
a, b = 0, 1
sequence = []
for _ in range(n):
sequence.append(a)
a, b = b, a + b
return sequence
print(MathUtils.is_even(4)) # True
print(MathUtils.factorial(5)) # 120
print(MathUtils.fibonacci(8)) # [0, 1, 1, 2, 3, 5, 8, 13]
Comparison Table
| Feature | Instance Method | Class Method | Static Method |
|---|---|---|---|
| First parameter | self (instance) | cls (class) | None |
| Decorator | None | @classmethod | @staticmethod |
| Access instance attrs | Yes | No | No |
| Access class attrs | Yes (via self.__class__ or class name) | Yes (via cls) | No |
| Modify instance state | Yes | No | No |
| Modify class state | Yes | Yes | No |
| Common use | Core behaviour | Factory methods, class-level operations | Utility/helper functions |
| Call syntax | obj.method() | Class.method() or obj.method() | Class.method() or obj.method() |
Factory Method Pattern
A practical use of @classmethod as a factory:
class Date:
def __init__(self, year, month, day):
self.year = year
self.month = month
self.day = day
@classmethod
def from_string(cls, date_string):
"""Create a Date from 'YYYY-MM-DD' format."""
year, month, day = map(int, date_string.split("-"))
return cls(year, month, day)
@classmethod
def today(cls):
"""Create a Date for today."""
import datetime
t = datetime.date.today()
return cls(t.year, t.month, t.day)
def __str__(self):
return f"{self.year:04d}-{self.month:02d}-{self.day:02d}"
# Three ways to create a Date
d1 = Date(2026, 3, 15)
d2 = Date.from_string("2026-03-15")
d3 = Date.today()
print(d1) # 2026-03-15
print(d2) # 2026-03-15
print(d3) # (today's date)
Composition vs Inheritance
Two fundamental ways to build relationships between classes: Inheritance ("is-a") and Composition ("has-a").
Inheritance — "Is-a" Relationship
Use when the child truly is a specialised type of the parent.
class Vehicle:
def __init__(self, brand, model, year):
self.brand = brand
self.model = model
self.year = year
def start(self):
return f"{self.brand} {self.model} is starting"
class Car(Vehicle): # A Car IS a Vehicle
def __init__(self, brand, model, year, num_doors=4):
super().__init__(brand, model, year)
self.num_doors = num_doors
class Motorcycle(Vehicle): # A Motorcycle IS a Vehicle
def __init__(self, brand, model, year, engine_cc):
super().__init__(brand, model, year)
self.engine_cc = engine_cc
Composition — "Has-a" Relationship
Use when a class contains or uses another class as a component.
class Engine:
def __init__(self, horsepower, fuel_type):
self.horsepower = horsepower
self.fuel_type = fuel_type
self.running = False
def start(self):
self.running = True
return f"Engine ({self.horsepower}HP, {self.fuel_type}) started"
def stop(self):
self.running = False
return "Engine stopped"
class Transmission:
def __init__(self, type_name, gears):
self.type_name = type_name
self.gears = gears
self.current_gear = 0
def shift_up(self):
if self.current_gear < self.gears:
self.current_gear += 1
return f"Gear: {self.current_gear}"
def shift_down(self):
if self.current_gear > 0:
self.current_gear -= 1
return f"Gear: {self.current_gear}"
class GPS:
def __init__(self):
self.destination = None
def set_destination(self, dest):
self.destination = dest
return f"Navigating to {dest}"
class Car:
"""A Car HAS an Engine, HAS a Transmission, HAS a GPS."""
def __init__(self, brand, model, engine, transmission):
self.brand = brand
self.model = model
self.engine = engine # Composition
self.transmission = transmission # Composition
self.gps = GPS() # Composition
def start(self):
return f"{self.brand} {self.model}: {self.engine.start()}"
def drive_to(self, destination):
if not self.engine.running:
self.start()
self.transmission.shift_up()
return self.gps.set_destination(destination)
# Build a car from components
engine = Engine(200, "Petrol")
trans = Transmission("Automatic", 6)
car = Car("Toyota", "Camry", engine, trans)
print(car.start()) # Toyota Camry: Engine (200HP, Petrol) started
print(car.drive_to("Mumbai")) # Navigating to Mumbai
print(car.transmission.shift_up()) # Gear: 2
When to Prefer Composition Over Inheritance
| Use Inheritance When | Use Composition When |
|---|---|
| There is a clear "is-a" relationship | There is a "has-a" relationship |
| The child is a specialised version of the parent | The object is assembled from parts |
| You want to reuse the parent's interface | You want flexibility to swap components |
| The hierarchy is shallow (2-3 levels max) | The relationship may change at runtime |
General guideline: Favour composition over inheritance. Deep inheritance hierarchies become rigid and hard to change. Composition gives you more flexibility.
# BAD: Over-using inheritance
class Animal:
pass
class FlyingAnimal(Animal):
def fly(self): ...
class SwimmingAnimal(Animal):
def swim(self): ...
# What about a duck that flies AND swims? Multiple inheritance gets messy.
# GOOD: Composition with behaviours
class FlyBehaviour:
def fly(self):
return "Flying!"
class SwimBehaviour:
def swim(self):
return "Swimming!"
class Duck:
def __init__(self):
self.fly_behaviour = FlyBehaviour()
self.swim_behaviour = SwimBehaviour()
def fly(self):
return self.fly_behaviour.fly()
def swim(self):
return self.swim_behaviour.swim()
d = Duck()
print(d.fly()) # Flying!
print(d.swim()) # Swimming!
Dataclasses
The dataclasses module (Python 3.7+) reduces boilerplate for classes that are primarily containers of data.
Basic Usage
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
# Auto-generated: __init__, __repr__, __eq__
p1 = Point(3, 4)
p2 = Point(3, 4)
p3 = Point(1, 2)
print(p1) # Point(x=3, y=4) — auto __repr__
print(p1 == p2) # True — auto __eq__
print(p1 == p3) # False
Compare this with the manual equivalent:
# Without dataclass — much more code for the same result
class PointManual:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"PointManual(x={self.x}, y={self.y})"
def __eq__(self, other):
if not isinstance(other, PointManual):
return NotImplemented
return self.x == other.x and self.y == other.y
Default Values
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
quantity: int = 0 # Default value
category: str = "General"
p = Product("Laptop", 75000)
print(p) # Product(name='Laptop', price=75000, quantity=0, category='General')
p2 = Product("Mouse", 500, 100, "Accessories")
print(p2) # Product(name='Mouse', price=500, quantity=100, category='Accessories')
The field() Function
For more control over individual fields — especially for mutable defaults.
from dataclasses import dataclass, field
@dataclass
class Student:
name: str
age: int
grades: list = field(default_factory=list) # Safe mutable default
student_id: str = field(default="", repr=False) # Hidden from repr
s = Student("Priya", 22)
s.grades.append(95)
s.grades.append(88)
print(s) # Student(name='Priya', age=22, grades=[95, 88])
# student_id not shown because repr=False
Important: Never use a mutable default directly (e.g.,
grades: list = []). Usefield(default_factory=list)instead. Dataclasses enforce this — using a mutable default raises aValueError.
Frozen Dataclasses (Immutable)
from dataclasses import dataclass
@dataclass(frozen=True)
class Coordinate:
latitude: float
longitude: float
c = Coordinate(28.6139, 77.2090)
print(c) # Coordinate(latitude=28.6139, longitude=77.209)
# Cannot modify — raises FrozenInstanceError
try:
c.latitude = 0
except Exception as e:
print(type(e).__name__) # FrozenInstanceError
# Frozen dataclasses are hashable — can be used in sets and as dict keys
locations = {c: "New Delhi"}
print(locations[c]) # New Delhi
Ordered Dataclasses
from dataclasses import dataclass
@dataclass(order=True)
class Student:
gpa: float
name: str = field(compare=False) # Exclude from comparison
students = [
Student(3.5, "Rahul"),
Student(3.9, "Priya"),
Student(3.7, "Vijay"),
]
students.sort()
for s in students:
print(s)
# Output (sorted by gpa):
# Student(gpa=3.5, name='Rahul')
# Student(gpa=3.7, name='Vijay')
# Student(gpa=3.9, name='Priya')
Post-Init Processing
from dataclasses import dataclass, field
@dataclass
class Rectangle:
width: float
height: float
area: float = field(init=False) # Excluded from __init__
def __post_init__(self):
"""Called automatically after __init__."""
if self.width <= 0 or self.height <= 0:
raise ValueError("Dimensions must be positive")
self.area = self.width * self.height
r = Rectangle(5, 3)
print(r) # Rectangle(width=5, height=3, area=15)
try:
bad = Rectangle(-1, 3)
except ValueError as e:
print(e) # Dimensions must be positive
Practical Examples
Bank Account System
from abc import ABC, abstractmethod
class BankAccount(ABC):
"""Abstract base class for bank accounts."""
_next_id = 1000
def __init__(self, owner, balance=0):
BankAccount._next_id += 1
self.account_id = BankAccount._next_id
self.owner = owner
self._balance = balance
self._transactions = []
@property
def balance(self):
return self._balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self._balance += amount
self._transactions.append(f"+{amount}")
return self
def withdraw(self, amount):
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
if amount > self._balance:
raise ValueError("Insufficient funds")
self._balance -= amount
self._transactions.append(f"-{amount}")
return self
@abstractmethod
def account_type(self):
pass
def statement(self):
print(f"\n--- {self.account_type()} ---")
print(f"Account: {self.account_id} | Owner: {self.owner}")
print(f"Transactions: {', '.join(self._transactions) if self._transactions else 'None'}")
print(f"Balance: Rs.{self._balance:,.2f}")
def __str__(self):
return f"{self.account_type()}({self.owner}, Rs.{self._balance:,.2f})"
class SavingsAccount(BankAccount):
def __init__(self, owner, balance=0, interest_rate=0.04):
super().__init__(owner, balance)
self.interest_rate = interest_rate
def account_type(self):
return "Savings Account"
def apply_interest(self):
interest = self._balance * self.interest_rate
self.deposit(interest)
return interest
class CheckingAccount(BankAccount):
def __init__(self, owner, balance=0, overdraft_limit=5000):
super().__init__(owner, balance)
self.overdraft_limit = overdraft_limit
def account_type(self):
return "Checking Account"
def withdraw(self, amount):
"""Override to allow overdraft."""
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
if amount > self._balance + self.overdraft_limit:
raise ValueError(f"Exceeds overdraft limit of Rs.{self.overdraft_limit}")
self._balance -= amount
self._transactions.append(f"-{amount}")
return self
# Usage
savings = SavingsAccount("Priya", 50000, interest_rate=0.06)
savings.deposit(10000).deposit(5000)
interest = savings.apply_interest()
print(f"Interest earned: Rs.{interest:,.2f}") # Interest earned: Rs.3,900.00
savings.statement()
checking = CheckingAccount("Rahul", 10000, overdraft_limit=5000)
checking.withdraw(12000) # Allowed — within overdraft limit
checking.statement()
print(f"\n{savings}") # Savings Account(Priya, Rs.68,900.00)
print(checking) # Checking Account(Rahul, Rs.-2,000.00)
Library Management System
from dataclasses import dataclass, field
from datetime import date, timedelta
@dataclass
class Book:
title: str
author: str
isbn: str
copies: int = 1
available: int = field(init=False)
def __post_init__(self):
self.available = self.copies
def checkout(self):
if self.available <= 0:
raise ValueError(f"'{self.title}' is not available")
self.available -= 1
def return_book(self):
if self.available >= self.copies:
raise ValueError(f"All copies of '{self.title}' already returned")
self.available += 1
def __str__(self):
return f"'{self.title}' by {self.author} ({self.available}/{self.copies} available)"
class Member:
MAX_BOOKS = 3
def __init__(self, name, member_id):
self.name = name
self.member_id = member_id
self.borrowed = [] # List of (book, due_date) tuples
def borrow(self, book, loan_days=14):
if len(self.borrowed) >= self.MAX_BOOKS:
raise ValueError(f"{self.name} has reached the borrowing limit ({self.MAX_BOOKS})")
book.checkout()
due = date.today() + timedelta(days=loan_days)
self.borrowed.append((book, due))
return f"{self.name} borrowed '{book.title}' — due {due}"
def return_book(self, book):
for i, (b, due) in enumerate(self.borrowed):
if b.isbn == book.isbn:
b.return_book()
self.borrowed.pop(i)
overdue = (date.today() - due).days
if overdue > 0:
return f"Returned '{book.title}' — {overdue} days overdue!"
return f"Returned '{book.title}' on time"
raise ValueError(f"{self.name} does not have '{book.title}'")
def __str__(self):
books = ", ".join(b.title for b, _ in self.borrowed) if self.borrowed else "None"
return f"Member({self.name}, borrowed: [{books}])"
class Library:
def __init__(self, name):
self.name = name
self.catalog = {} # isbn -> Book
self.members = {} # member_id -> Member
def add_book(self, book):
if book.isbn in self.catalog:
self.catalog[book.isbn].copies += book.copies
self.catalog[book.isbn].available += book.copies
else:
self.catalog[book.isbn] = book
return f"Added '{book.title}' to {self.name}"
def register_member(self, member):
self.members[member.member_id] = member
return f"Registered {member.name}"
def search(self, query):
"""Search books by title or author (case-insensitive)."""
query = query.lower()
results = [
book for book in self.catalog.values()
if query in book.title.lower() or query in book.author.lower()
]
return results
def status(self):
print(f"\n=== {self.name} ===")
print(f"Books: {len(self.catalog)} titles")
print(f"Members: {len(self.members)}")
for book in self.catalog.values():
print(f" {book}")
# Usage
library = Library("City Central Library")
# Add books
b1 = Book("Python Crash Course", "Eric Matthes", "978-1", 3)
b2 = Book("Clean Code", "Robert Martin", "978-2", 2)
b3 = Book("The Pragmatic Programmer", "David Thomas", "978-3", 1)
for book in [b1, b2, b3]:
print(library.add_book(book))
# Register members
m1 = Member("Priya", "M001")
m2 = Member("Rahul", "M002")
library.register_member(m1)
library.register_member(m2)
# Borrow and return
print(m1.borrow(b1)) # Priya borrowed 'Python Crash Course' — due ...
print(m2.borrow(b1)) # Rahul borrowed 'Python Crash Course' — due ...
library.status()
# === City Central Library ===
# Books: 3 titles
# Members: 2
# 'Python Crash Course' by Eric Matthes (1/3 available)
# 'Clean Code' by Robert Martin (2/2 available)
# 'The Pragmatic Programmer' by David Thomas (1/1 available)
Employee Hierarchy
from abc import ABC, abstractmethod
class Employee(ABC):
def __init__(self, name, emp_id, base_salary):
self.name = name
self.emp_id = emp_id
self.base_salary = base_salary
@abstractmethod
def calculate_pay(self):
pass
def __str__(self):
return f"{self.__class__.__name__}({self.name}, Rs.{self.calculate_pay():,.0f})"
class FullTimeEmployee(Employee):
def __init__(self, name, emp_id, base_salary, bonus_pct=0.10):
super().__init__(name, emp_id, base_salary)
self.bonus_pct = bonus_pct
def calculate_pay(self):
return self.base_salary * (1 + self.bonus_pct)
class PartTimeEmployee(Employee):
def __init__(self, name, emp_id, hourly_rate, hours_worked):
super().__init__(name, emp_id, 0)
self.hourly_rate = hourly_rate
self.hours_worked = hours_worked
def calculate_pay(self):
return self.hourly_rate * self.hours_worked
class Manager(FullTimeEmployee):
def __init__(self, name, emp_id, base_salary, bonus_pct=0.15, team=None):
super().__init__(name, emp_id, base_salary, bonus_pct)
self.team = team or []
def add_member(self, employee):
self.team.append(employee)
def team_cost(self):
return sum(e.calculate_pay() for e in self.team) + self.calculate_pay()
# Usage
dev1 = FullTimeEmployee("Priya", "E001", 80000)
dev2 = FullTimeEmployee("Rahul", "E002", 75000)
intern = PartTimeEmployee("Anita", "E003", 300, 120)
manager = Manager("Vijay", "M001", 120000)
manager.add_member(dev1)
manager.add_member(dev2)
manager.add_member(intern)
employees = [dev1, dev2, intern, manager]
for e in employees:
print(e)
# Output:
# FullTimeEmployee(Priya, Rs.88,000)
# FullTimeEmployee(Rahul, Rs.82,500)
# PartTimeEmployee(Anita, Rs.36,000)
# Manager(Vijay, Rs.138,000)
print(f"\nTotal team cost: Rs.{manager.team_cost():,.0f}")
# Total team cost: Rs.344,500
Custom Collection Class
class SortedList:
"""A list that keeps its elements in sorted order."""
def __init__(self, initial=None):
self._data = sorted(initial) if initial else []
def add(self, value):
"""Insert value in the correct sorted position."""
# Binary search for the insertion point
lo, hi = 0, len(self._data)
while lo < hi:
mid = (lo + hi) // 2
if self._data[mid] < value:
lo = mid + 1
else:
hi = mid
self._data.insert(lo, value)
def remove(self, value):
self._data.remove(value)
def __len__(self):
return len(self._data)
def __getitem__(self, index):
return self._data[index]
def __contains__(self, value):
# Efficient binary search
lo, hi = 0, len(self._data) - 1
while lo <= hi:
mid = (lo + hi) // 2
if self._data[mid] == value:
return True
elif self._data[mid] < value:
lo = mid + 1
else:
hi = mid - 1
return False
def __iter__(self):
return iter(self._data)
def __repr__(self):
return f"SortedList({self._data})"
# Usage
sl = SortedList([5, 2, 8, 1])
print(sl) # SortedList([1, 2, 5, 8])
sl.add(3)
sl.add(7)
print(sl) # SortedList([1, 2, 3, 5, 7, 8])
print(len(sl)) # 6
print(sl[2]) # 3
print(5 in sl) # True
print(99 in sl) # False
for x in sl:
print(x, end=" ")
# 1 2 3 5 7 8
SOLID Principles (Brief Overview)
SOLID is a set of five design principles that help you write maintainable, flexible object-oriented code.
S — Single Responsibility Principle
A class should have one reason to change — it should do one thing and do it well.
# BAD: One class doing too many things
class UserBad:
def __init__(self, name, email):
self.name = name
self.email = email
def save_to_database(self):
# Database logic mixed with user logic
print(f"Saving {self.name} to database")
def send_welcome_email(self):
# Email logic mixed with user logic
print(f"Sending email to {self.email}")
# GOOD: Separate responsibilities
class User:
def __init__(self, name, email):
self.name = name
self.email = email
class UserRepository:
def save(self, user):
print(f"Saving {user.name} to database")
class EmailService:
def send_welcome(self, user):
print(f"Sending welcome email to {user.email}")
user = User("Priya", "priya@example.com")
UserRepository().save(user)
EmailService().send_welcome(user)
O — Open/Closed Principle
Classes should be open for extension but closed for modification. Add new behaviour by adding new code, not changing existing code.
from abc import ABC, abstractmethod
# GOOD: Add new discount types without modifying existing code
class Discount(ABC):
@abstractmethod
def calculate(self, price):
pass
class NoDiscount(Discount):
def calculate(self, price):
return price
class PercentageDiscount(Discount):
def __init__(self, percent):
self.percent = percent
def calculate(self, price):
return price * (1 - self.percent / 100)
class FlatDiscount(Discount):
def __init__(self, amount):
self.amount = amount
def calculate(self, price):
return max(0, price - self.amount)
# Adding a new discount type requires NO changes to existing classes
class BuyOneGetOneFree(Discount):
def calculate(self, price):
return price / 2
def apply_discount(price, discount: Discount):
return discount.calculate(price)
print(apply_discount(1000, NoDiscount())) # 1000
print(apply_discount(1000, PercentageDiscount(20))) # 800.0
print(apply_discount(1000, FlatDiscount(150))) # 850
print(apply_discount(1000, BuyOneGetOneFree())) # 500.0
L — Liskov Substitution Principle
Subclasses should be usable in place of their parent class without breaking the program.
class Bird:
def move(self):
return "Moving"
class Sparrow(Bird):
def move(self):
return "Flying through the air"
class Penguin(Bird):
def move(self):
return "Waddling on the ground"
# Both subclasses work correctly when treated as Bird
def make_bird_move(bird: Bird):
print(bird.move())
make_bird_move(Sparrow()) # Flying through the air
make_bird_move(Penguin()) # Waddling on the ground
# NOTE: If Bird had a fly() method, Penguin would violate LSP
# because penguins cannot fly. Using move() avoids this problem.
I — Interface Segregation Principle
Clients should not be forced to depend on methods they do not use. Prefer many small, specific interfaces over one large interface.
from abc import ABC, abstractmethod
# BAD: One fat interface forces all implementations to handle everything
# class Worker(ABC):
# @abstractmethod
# def work(self): ...
# @abstractmethod
# def eat(self): ...
# @abstractmethod
# def sleep(self): ...
# A Robot would have to implement eat() and sleep() — meaningless.
# GOOD: Separate interfaces
class Workable(ABC):
@abstractmethod
def work(self):
pass
class Eatable(ABC):
@abstractmethod
def eat(self):
pass
class HumanWorker(Workable, Eatable):
def work(self):
return "Human working"
def eat(self):
return "Human eating lunch"
class RobotWorker(Workable):
def work(self):
return "Robot working tirelessly"
# No need to implement eat() — robots do not eat
h = HumanWorker()
r = RobotWorker()
print(h.work()) # Human working
print(h.eat()) # Human eating lunch
print(r.work()) # Robot working tirelessly
D — Dependency Inversion Principle
High-level modules should depend on abstractions, not concrete implementations.
from abc import ABC, abstractmethod
# Abstraction
class Database(ABC):
@abstractmethod
def save(self, data):
pass
@abstractmethod
def find(self, query):
pass
# Concrete implementations
class PostgresDatabase(Database):
def save(self, data):
return f"Saved to PostgreSQL: {data}"
def find(self, query):
return f"PostgreSQL found: {query}"
class MongoDatabase(Database):
def save(self, data):
return f"Saved to MongoDB: {data}"
def find(self, query):
return f"MongoDB found: {query}"
# High-level module depends on the ABSTRACTION (Database), not a specific DB
class UserService:
def __init__(self, db: Database): # Accepts any Database implementation
self.db = db
def create_user(self, name):
return self.db.save({"name": name})
def find_user(self, name):
return self.db.find({"name": name})
# Easy to swap implementations
service_pg = UserService(PostgresDatabase())
service_mongo = UserService(MongoDatabase())
print(service_pg.create_user("Priya")) # Saved to PostgreSQL: {'name': 'Priya'}
print(service_mongo.create_user("Priya")) # Saved to MongoDB: {'name': 'Priya'}
Common Mistakes
1. Forgetting self
The most common beginner mistake — forgetting self in method definitions or when accessing attributes.
# WRONG
class Dog:
def __init__(name): # Missing self!
name = name # This creates a local variable, not an attribute
def bark(): # Missing self!
return f"{name} says Woof" # name is not defined here
# CORRECT
class Dog:
def __init__(self, name):
self.name = name
def bark(self):
return f"{self.name} says Woof"
2. Mutable Class Attributes
As covered earlier, mutable class-level defaults are shared across all instances.
# WRONG
class Team:
members = [] # Shared across ALL teams!
def add(self, name):
self.members.append(name)
t1 = Team()
t2 = Team()
t1.add("Priya")
print(t2.members) # ['Priya'] — t2 sees t1's member!
# CORRECT
class Team:
def __init__(self):
self.members = [] # Each team gets its own list
def add(self, name):
self.members.append(name)
3. Deep vs Shallow Copy of Objects
When you copy an object, the default is a shallow copy — nested objects are shared, not duplicated.
import copy
class Address:
def __init__(self, city, state):
self.city = city
self.state = state
class Person:
def __init__(self, name, address):
self.name = name
self.address = address
addr = Address("Mumbai", "Maharashtra")
p1 = Person("Priya", addr)
# Shallow copy — address is shared
p2 = copy.copy(p1)
p2.name = "Rahul"
p2.address.city = "Pune"
print(p1.address.city) # Pune — p1's address was changed too!
# Deep copy — completely independent
p3 = copy.deepcopy(p1)
p3.address.city = "Delhi"
print(p1.address.city) # Pune — p1 is unaffected by p3's change
print(p3.address.city) # Delhi
4. Overusing Inheritance
Not every relationship should be modelled with inheritance. If you find yourself creating deep hierarchies or inheriting just to reuse a couple of methods, prefer composition.
# QUESTIONABLE: Stack inherits from list
class Stack(list):
def push(self, item):
self.append(item)
def peek(self):
return self[-1] if self else None
# Problem: Users can call ALL list methods — insert, sort, reverse, etc.
s = Stack()
s.push(1)
s.push(2)
s.insert(0, 99) # This should not be allowed for a stack!
print(s) # [99, 1, 2]
# BETTER: Composition — hide the list
class Stack:
def __init__(self):
self._data = []
def push(self, item):
self._data.append(item)
def pop(self):
if not self._data:
raise IndexError("Stack is empty")
return self._data.pop()
def peek(self):
if not self._data:
return None
return self._data[-1]
def __len__(self):
return len(self._data)
def is_empty(self):
return len(self._data) == 0
Practice Exercises
Exercise 1: Shape Calculator
Create a Shape abstract base class with area() and perimeter() abstract methods. Implement Circle, Rectangle, and Square (inheriting from Rectangle) classes. Write a function that takes a list of shapes and prints a report of each shape's area and perimeter.
Exercise 2: Bank System
Build a mini banking system with:
- A
BankAccountbase class withdeposit(),withdraw(), andtransfer(other_account, amount)methods. - A
SavingsAccountthat earns interest and has a minimum balance requirement. - A
CurrentAccountwith an overdraft facility. - Track all transactions with timestamps.
Exercise 3: Custom Linked List
Implement a LinkedList class with:
append(value),prepend(value),remove(value)methods- Support for
len(),inoperator, iteration (for x in linked_list), and indexing (linked_list[2]) - A
__repr__that shows the list like1 -> 2 -> 3 -> None
Exercise 4: Deck of Cards
Create a Card class and a Deck class. The deck should support shuffling, dealing cards, and comparing hands. Use dunder methods so that cards can be compared (card1 > card2) and the deck supports len() and iteration.
Exercise 5: Plugin System
Design a plugin system using ABCs:
- An abstract
Pluginclass withname,execute(data), anddescriptionmethods. - Implement
UpperCasePlugin,ReversePlugin, andCensorPlugin(replaces specified words with***). - A
Pipelineclass that chains multiple plugins:Pipeline([plugin1, plugin2]).run(data).
Exercise 6: Inventory Management
Create an inventory system with:
- A
Productdataclass with name, price, quantity, and category. - An
Inventoryclass that supports adding products, removing products, searching by category, calculating total value, and generating a stock report. - Use
@propertyfor validation (price and quantity must be non-negative).
Summary
In this chapter, you learned the foundations and advanced features of Object-Oriented Programming in Python:
- Classes and Objects — A class is a blueprint; an object is an instance. Use
__init__to set up initial state andselfto refer to the current instance. - Instance vs Class Attributes — Instance attributes are unique per object; class attributes are shared. Avoid mutable class-level defaults.
- Methods — Instance methods operate on
self; class methods operate oncls; static methods are utility functions with no access to instance or class state. - Encapsulation — Use naming conventions (
_protected,__private) and@propertydecorators to control access to an object's internals. - Inheritance — Child classes inherit from parent classes. Use
super()to call parent methods. The MRO determines method lookup order. - Multiple Inheritance — Python supports it with C3 linearization. Use mixins for clean, composable functionality.
- Polymorphism — Different objects responding to the same interface. Python embraces duck typing: if an object has the right methods, it works.
- Abstraction — Use ABCs from the
abcmodule to define contracts that subclasses must fulfil. - Dunder Methods — Implement
__str__,__repr__,__len__,__eq__,__add__,__iter__, and others to make your classes work naturally with Python's built-in operations. - Composition vs Inheritance — Favour "has-a" (composition) over "is-a" (inheritance) for flexibility.
- Dataclasses — Use
@dataclassto reduce boilerplate for data-centric classes. - SOLID Principles — Five design guidelines for writing clean, maintainable OOP code.
OOP is a skill that deepens with practice. Start by modelling simple real-world entities as classes, then gradually apply inheritance, composition, and design principles as your projects grow.