class BankAccount:
# Class variable - same for all accounts
bank_name = "State Bank of India"
def __init__(self, owner, balance=0):
# Instance variables - unique per account
self.owner = owner
self.balance = balance
def deposit(self, amount):
if amount > 0:
self.balance += amount
print(f"₹{amount} deposited. New balance: ₹{self.balance}")
else:
print("Invalid amount!")
def withdraw(self, amount):
if amount > self.balance:
print("Insufficient balance!")
elif amount <= 0:
print("Invalid amount!")
else:
self.balance -= amount
print(f"₹{amount} withdrawn. New balance: ₹{self.balance}")
def check_balance(self):
print(f"{self.owner}'s balance: ₹{self.balance}")
# Create accounts
acc1 = BankAccount("Rahul", 5000)
acc2 = BankAccount("Priya") # balance defaults to 0
acc1.deposit(3000) # ₹3000 deposited. New balance: ₹8000
acc1.withdraw(2000) # ₹2000 withdrawn. New balance: ₹6000
acc2.deposit(10000) # ₹10000 deposited. New balance: ₹10000
acc2.withdraw(15000) # Insufficient balance!
acc1.check_balance() # Rahul's balance: ₹6000
Before OOPs — Procedural Programming
What is Procedural Programming?
In procedural programming, you write a list of instructions (procedures/functions) that execute one after another. The data and functions are separate — functions take data, process it, return results.
Think of it like writing a to-do list: do this, then this, then this. Simple and works fine for small programs.
A Simple Procedural Example
# Procedural approach - managing students
# Data is just stored in separate variables/lists
student1_name = "Rahul"
student1_marks = 85
student1_grade = ""
student2_name = "Priya"
student2_marks = 92
student2_grade = ""
# Function to calculate grade
def calculate_grade(marks):
if marks >= 90:
return "A"
elif marks >= 80:
return "B"
else:
return "C"
# Function to display student
def display_student(name, marks, grade):
print(f"Name: {name}, Marks: {marks}, Grade: {grade}")
# Calculate grades
student1_grade = calculate_grade(student1_marks)
student2_grade = calculate_grade(student2_marks)
# Display
display_student(student1_name, student1_marks, student1_grade)
display_student(student2_name, student2_marks, student2_grade)
Why Does This Become Messy?
student999_name, student999_marks? Pagal ho jaoge! Aur agar ek naya field add karna hai jaise "phone number" toh har jagah change karna padega.
Problems with procedural approach for big projects:
- Data is scattered — name, marks, grade are in separate variables with no connection
- Hard to scale — adding 100 students means 300 variables
- No protection — anyone can change any variable from anywhere
- Hard to maintain — changing one thing breaks many things
- Code duplication — same logic repeated for every student
Why OOPs Was Invented
Programmers realized: "Real world mein cheezein objects hain. Ek student ek object hai jisme name, marks, grade SAB ek saath hai. Toh code mein bhi aisa kyun nahi karein?"
OOPs was invented to:
- Group related data and functions together (student ka data + student ke functions = one unit)
- Protect data from accidental changes
- Reuse code easily (write once, use many times)
- Model real-world things in code naturally
What is OOPs?
Simple Definition
Object-Oriented Programming (OOPs) is a way of writing code where you organize everything into "objects". An object is a bundle of data (what it has) and functions (what it can do).
- Data (properties): color, brand, speed, fuel level
- Functions (behaviors): start(), drive(), brake(), honk()
The 4 Pillars of OOPs
Encapsulation
Data ko protect karo. Bahar se direct access mat do.
Inheritance
Parent se properties le lo. Code reuse karo.
Polymorphism
Ek naam, alag alag kaam. Same function, different behavior.
Abstraction
Complexity chhupao. Sirf zaroori cheez dikhao.
Why OOPs Matters in Interviews
- Every single Python/Java/C++ interview asks OOPs questions
- System design rounds require OOPs thinking
- Real companies' codebases are built with OOPs
- Without OOPs, you cannot write clean, maintainable code
Classes and Objects
What is a Class?
A class is a blueprint/template that defines:
- What data (attributes/properties) an object will have
- What actions (methods/functions) an object can perform
What is an Object?
An object is an actual instance created from a class. It has real data and can perform real actions.
Object = Actual Cookie (the real cookie you eat)
One cookie cutter → many cookies. Same shape, but each cookie can have different toppings (data).
Creating Your First Class
# Step 1: Create a class (blueprint)
class Student:
pass # 'pass' means "nothing here yet" - empty class
# Step 2: Create an object (actual student)
student1 = Student() # This creates an object!
print(type(student1)) # <class '__main__.Student'>
print(student1) # <__main__.Student object at 0x...>
class Student: — ye class banayi (blueprint)student1 = Student() — ye object banaya (actual student)() lagana zaroori hai object banate waqt — ye class ko "call" karta hai.
The __init__ Method (Constructor) — Step by Step
Right now our Student class is empty. We need to give it some data. That's where __init__ comes in.
class Student:
# __init__ runs AUTOMATICALLY when you create an object
# It "initializes" (sets up) the object with data
def __init__(self, name, marks):
self.name = name # Store name IN this object
self.marks = marks # Store marks IN this object
# Create objects - __init__ runs automatically!
s1 = Student("Rahul", 85) # name="Rahul", marks=85
s2 = Student("Priya", 92) # name="Priya", marks=92
print(s1.name) # Rahul
print(s2.marks) # 92
92
Understanding self — The Most Confusing Part
self matlab "main khud" — ye object apne aap ko refer karta hai.Socho class mein 100 students hain. Jab Rahul ka object
s1.name puchhe toh Python ko kaise pata chalega ki Rahul ka naam chahiye, Priya ka nahi? self yehi kaam karta hai. Jab s1.name likhte ho, Python internally Student.name(s1) call karta hai. self = s1 ho jaata hai. Toh self.name = "Rahul" matlab "s1 ka naam Rahul hai."
class Student:
def __init__(self, name):
# When s1 = Student("Rahul") is called:
# self = s1 (the object being created)
# name = "Rahul" (the argument you passed)
self.name = name
# This means: s1.name = "Rahul"
def greet(self):
# When s1.greet() is called:
# self = s1
# So self.name = s1.name = "Rahul"
print(f"Hi, I am {self.name}")
s1 = Student("Rahul")
s2 = Student("Priya")
s1.greet() # Hi, I am Rahul (self = s1)
s2.greet() # Hi, I am Priya (self = s2)
self likhna bhoolna! Har method ka first parameter self hona chahiye. Agar bhool gaye toh error aayega: TypeError: greet() takes 0 positional arguments but 1 was given
Instance Variables vs Class Variables
class Student:
# CLASS VARIABLE - shared by ALL objects (same for everyone)
school = "ABC School"
def __init__(self, name, marks):
# INSTANCE VARIABLES - unique to each object (different for each)
self.name = name
self.marks = marks
s1 = Student("Rahul", 85)
s2 = Student("Priya", 92)
# Instance variables are DIFFERENT for each object
print(s1.name) # Rahul
print(s2.name) # Priya
# Class variable is SAME for all objects
print(s1.school) # ABC School
print(s2.school) # ABC School
# Change class variable - changes for ALL
Student.school = "XYZ School"
print(s1.school) # XYZ School
print(s2.school) # XYZ School
| Feature | Instance Variable | Class Variable |
|---|---|---|
| Defined where? | Inside __init__ with self.x | Inside class, outside any method |
| Unique per object? | Yes (each object has its own) | No (shared by all objects) |
| Access | object.variable | Class.variable or object.variable |
| Example | name, marks, age | school, total_students |
Example: BankAccount Class
Full BankAccount Example (Click to expand)
Example: Car Class
Full Car Example (Click to expand)
class Car:
def __init__(self, brand, color, speed=0):
self.brand = brand
self.color = color
self.speed = speed
self.is_running = False
def start(self):
self.is_running = True
print(f"{self.brand} started! 🚗")
def accelerate(self, increase):
if not self.is_running:
print("Start the car first!")
return
self.speed += increase
print(f"Speed: {self.speed} km/h")
def brake(self):
self.speed = 0
print("Car stopped!")
def info(self):
print(f"{self.color} {self.brand} | Speed: {self.speed} km/h")
# Multiple objects from same class
car1 = Car("Maruti Swift", "Red")
car2 = Car("Hyundai Creta", "White")
car1.start() # Maruti Swift started!
car1.accelerate(60) # Speed: 60 km/h
car2.accelerate(40) # Start the car first!
car1.info() # Red Maruti Swift | Speed: 60 km/h
Q: What is the difference between a class and an object?
A: A class is a blueprint/template. An object is an actual instance created from that blueprint. One class can create many objects.
Methods
What Are Methods?
start(), drive(), brake() — ye sab methods hain.
A method is simply a function that belongs to a class. It defines what an object can do.
Instance Methods
Instance methods are the most common type. They work on a specific object's data using self.
class Calculator:
def __init__(self):
self.result = 0 # Each calculator has its own result
def add(self, number):
self.result += number
return self # Returning self allows method chaining!
def subtract(self, number):
self.result -= number
return self
def multiply(self, number):
self.result *= number
return self
def divide(self, number):
if number == 0:
print("Cannot divide by zero!")
return self
self.result /= number
return self
def reset(self):
self.result = 0
return self
def show(self):
print(f"Result: {self.result}")
return self
# Normal usage
calc = Calculator()
calc.add(10)
calc.multiply(3)
calc.show() # Result: 30
# Method chaining (because we return self)
Calculator().add(100).subtract(20).multiply(2).show() # Result: 160
Methods with Parameters and Return Values
class Rectangle:
def __init__(self, length, width):
self.length = length
self.width = width
def area(self):
return self.length * self.width # Returns a value
def perimeter(self):
return 2 * (self.length + self.width)
def is_square(self):
return self.length == self.width # Returns True/False
def resize(self, factor): # Method with parameter
self.length *= factor
self.width *= factor
rect = Rectangle(5, 3)
print(f"Area: {rect.area()}") # Area: 15
print(f"Perimeter: {rect.perimeter()}") # Perimeter: 16
print(f"Is square? {rect.is_square()}") # Is square? False
rect.resize(2) # Double the size
print(f"New area: {rect.area()}") # New area: 60
None (or self for chaining). If it calculates something, it returns the result.
Encapsulation
What is Encapsulation?
Jaise ATM machine: tum button dabate ho, paisa nikalte ho. Par andar ka mechanism (kitne notes hain, safe ka lock) tumhe nahi dikhta aur tum directly change nahi kar sakte. ATM ne apna internal data "encapsulate" (pack) kar rakha hai.
Encapsulation = Bundling data + methods together AND restricting direct access to data.
Access Modifiers in Python
| Type | Syntax | Access | Example |
|---|---|---|---|
| Public | self.name | Accessible from anywhere | self.name = "Rahul" |
| Protected | self._name | Convention: don't access from outside (can still be accessed) | self._salary = 50000 |
| Private | self.__name | Cannot access directly from outside (name mangling) | self.__password = "secret" |
class Employee:
def __init__(self, name, salary, password):
self.name = name # Public - accessible from anywhere
self._salary = salary # Protected - "please don't touch"
self.__password = password # Private - can't access directly
emp = Employee("Rahul", 50000, "secret123")
print(emp.name) # ✅ Works - Rahul
print(emp._salary) # ✅ Works (but shouldn't do this) - 50000
# print(emp.__password) # ❌ ERROR! AttributeError!
# Python uses "name mangling" - it renames __password internally
print(emp._Employee__password) # This works but NEVER do this!
Getter and Setter Methods
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.__balance = balance # Private! No direct access
# GETTER - safe way to READ balance
def get_balance(self):
return self.__balance
# SETTER - safe way to CHANGE balance (with validation!)
def set_balance(self, amount):
if amount < 0:
print("Balance cannot be negative!")
return
self.__balance = amount
acc = BankAccount("Rahul", 10000)
print(acc.get_balance()) # 10000 ✅ Safe way to read
acc.set_balance(50000) # ✅ Safe way to write
acc.set_balance(-500) # Balance cannot be negative!
The Pythonic Way: @property Decorator
@property ek shortcut hai. Isse tum getter/setter ko aise use kar sakte ho jaise normal variable ho. acc.balance likhne se getter call hota hai, acc.balance = 500 likhne se setter call hota hai.
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.__balance = balance
@property
def balance(self):
"""Getter - called when you READ balance"""
return self.__balance
@balance.setter
def balance(self, amount):
"""Setter - called when you WRITE balance"""
if amount < 0:
raise ValueError("Balance cannot be negative!")
self.__balance = amount
acc = BankAccount("Rahul", 10000)
# Looks like normal variable access, but calls getter/setter!
print(acc.balance) # 10000 (calls @property getter)
acc.balance = 50000 # (calls @balance.setter)
print(acc.balance) # 50000
# acc.balance = -500 # ValueError: Balance cannot be negative!
A: Encapsulation is bundling data and methods that operate on that data within a single unit (class), and restricting direct access to some of the object's components. In Python, we use underscore conventions (_ for protected, __ for private) and @property decorators to achieve this.
Inheritance
What is Inheritance?
Code mein bhi yehi hota hai: Child class apne Parent class ki saari properties aur methods inherit kar leti hai. Aur apni extra cheezein bhi add kar sakti hai.
Basic Inheritance
# PARENT class (base class / super class)
class Animal:
def __init__(self, name, species):
self.name = name
self.species = species
def eat(self):
print(f"{self.name} is eating")
def sleep(self):
print(f"{self.name} is sleeping")
# CHILD class (derived class / sub class)
# Dog INHERITS from Animal - gets name, species, eat(), sleep() for FREE!
class Dog(Animal): # ← The (Animal) means "inherits from Animal"
def __init__(self, name, breed):
super().__init__(name, "Dog") # Call parent's __init__
self.breed = breed # Dog's own extra property
def bark(self): # Dog's own extra method
print(f"{self.name} says: Woof! 🐕")
# Dog gets everything from Animal + its own stuff
dog = Dog("Tommy", "Labrador")
dog.eat() # Tommy is eating ← Inherited from Animal
dog.sleep() # Tommy is sleeping ← Inherited from Animal
dog.bark() # Tommy says: Woof! ← Dog's own method
print(dog.species) # Dog ← Inherited from Animal
print(dog.breed) # Labrador ← Dog's own property
Understanding super()
super() matlab "parent class ko call karo." Jab child class mein __init__ likhte ho toh parent ka __init__ automatically nahi chalta. Tumhe super().__init__() likh ke manually call karna padta hai. Socho: beta apni nayi properties add kar raha hai, par pehle papa ki properties bhi chahiye.
super().__init__() = "papa, pehle aap apna setup kar lo."
Method Overriding
When a child class has the same method name as parent, the child's version is used. This is called method overriding.
class Animal:
def speak(self):
print("Animal speaks")
class Cat(Animal):
def speak(self): # Same method name → OVERRIDES parent's speak()
print("Meow! 🐱")
class Dog(Animal):
def speak(self): # Same method name → OVERRIDES parent's speak()
print("Woof! 🐕")
cat = Cat()
dog = Dog()
cat.speak() # Meow! (Cat's version, not Animal's)
dog.speak() # Woof! (Dog's version, not Animal's)
Types of Inheritance
1. Single Inheritance
One child, one parent. Simple.
# Parent → Child
class Parent:
def feature1(self):
print("Feature 1 from Parent")
class Child(Parent):
def feature2(self):
print("Feature 2 from Child")
c = Child()
c.feature1() # Inherited from Parent
c.feature2() # Child's own
2. Multiple Inheritance
One child inherits from MULTIPLE parents.
# Parent1 + Parent2 → Child
class Father:
def skill_coding(self):
print("Father taught: Coding")
class Mother:
def skill_painting(self):
print("Mother taught: Painting")
class Child(Father, Mother): # Inherits from BOTH
def skill_gaming(self):
print("Child's own: Gaming")
c = Child()
c.skill_coding() # From Father
c.skill_painting() # From Mother
c.skill_gaming() # Child's own
3. Multilevel Inheritance
Chain: Grandparent → Parent → Child
class Grandparent:
def property1(self):
print("Grandparent's house")
class Parent(Grandparent):
def property2(self):
print("Parent's car")
class Child(Parent): # Gets everything from Parent AND Grandparent
def property3(self):
print("Child's laptop")
c = Child()
c.property1() # From Grandparent!
c.property2() # From Parent
c.property3() # Own
4. Hierarchical Inheritance
One parent, multiple children.
class Vehicle:
def start(self):
print("Vehicle started")
class Car(Vehicle):
def drive(self):
print("Car driving on road")
class Bike(Vehicle):
def ride(self):
print("Bike riding")
class Truck(Vehicle):
def haul(self):
print("Truck hauling cargo")
# All three inherit start() from Vehicle
5. Hybrid Inheritance
Combination of two or more types of inheritance.
class A:
def method_a(self):
print("A")
class B(A): # Single from A
def method_b(self):
print("B")
class C(A): # Single from A (hierarchical)
def method_c(self):
print("C")
class D(B, C): # Multiple from B and C (hybrid!)
def method_d(self):
print("D")
d = D()
d.method_a() # From A (through B or C)
d.method_b() # From B
d.method_c() # From C
d.method_d() # Own
The Diamond Problem & MRO
method_a() call kare toh kiska chalega? B wala ya C wala? Python isko MRO (Method Resolution Order) se solve karta hai. Ye ek fixed order follow karta hai: D → B → C → A. Matlab pehle D mein dhundho, phir B, phir C, phir A.
class A:
def show(self):
print("A")
class B(A):
def show(self):
print("B")
class C(A):
def show(self):
print("C")
class D(B, C):
pass
d = D()
d.show() # B (follows MRO: D → B → C → A)
# Check MRO
print(D.mro()) # [D, B, C, A, object]
isinstance() and issubclass()
class Animal: pass
class Dog(Animal): pass
dog = Dog()
# isinstance: Is this object an instance of this class?
print(isinstance(dog, Dog)) # True
print(isinstance(dog, Animal)) # True (Dog IS an Animal!)
# issubclass: Is this class a child of that class?
print(issubclass(Dog, Animal)) # True
print(issubclass(Animal, Dog)) # False (Animal is NOT a Dog)
A: Multiple = one child inherits from 2+ parents (Child(Parent1, Parent2)). Multilevel = chain inheritance (Grandparent → Parent → Child). Remember: Multiple = horizontal, Multilevel = vertical.
Polymorphism
What is Polymorphism?
Jaise word "open":
- "Open the door" → darwaza kholo
- "Open the book" → kitaab kholo
- "Open the app" → app kholo
Same word "open" but DIFFERENT actions depending on context. Code mein bhi same method name, par different classes mein different behavior.
Method Overriding (Runtime Polymorphism)
class Shape:
def area(self):
pass
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self): # Same name, different implementation
return 3.14 * self.radius ** 2
class Rectangle(Shape):
def __init__(self, length, width):
self.length = length
self.width = width
def area(self): # Same name, different implementation
return self.length * self.width
class Triangle(Shape):
def __init__(self, base, height):
self.base = base
self.height = height
def area(self): # Same name, different implementation
return 0.5 * self.base * self.height
# POLYMORPHISM in action: same method name, different behavior!
shapes = [Circle(5), Rectangle(4, 6), Triangle(3, 8)]
for shape in shapes:
print(f"{type(shape).__name__} area: {shape.area()}")
Rectangle area: 24
Triangle area: 12.0
Method Overloading (Python's Way)
# Python's alternative to method overloading
class MathOps:
# Using default parameters
def add(self, a, b=0, c=0):
return a + b + c
# Using *args (variable number of arguments)
def multiply(self, *args):
result = 1
for num in args:
result *= num
return result
m = MathOps()
print(m.add(5)) # 5 (b=0, c=0)
print(m.add(5, 3)) # 8 (c=0)
print(m.add(5, 3, 2)) # 10
print(m.multiply(2, 3)) # 6
print(m.multiply(2,3,4)) # 24
Duck Typing
Python ko class ka type nahi chahiye. Agar object mein wo method hai jo chahiye, toh Python use kar lega. Type check nahi karta.
class Duck:
def sound(self):
print("Quack!")
class Person:
def sound(self):
print("I can quack too!")
# Python doesn't care about the TYPE, only that sound() exists
def make_sound(thing):
thing.sound() # Works for ANY object that has sound() method
make_sound(Duck()) # Quack!
make_sound(Person()) # I can quack too!
Operator Overloading
+ operator numbers ke liye add karta hai, strings ke liye concatenate karta hai. Tum apni class mein bhi define kar sakte ho ki + kya karega. Isko operator overloading kehte hain.
class Money:
def __init__(self, amount):
self.amount = amount
def __add__(self, other): # Defines what + does
return Money(self.amount + other.amount)
def __str__(self): # Defines what print() shows
return f"₹{self.amount}"
def __gt__(self, other): # Defines what > does
return self.amount > other.amount
m1 = Money(500)
m2 = Money(300)
m3 = m1 + m2 # Uses __add__
print(m3) # ₹800 (uses __str__)
print(m1 > m2) # True (uses __gt__)
A: Overloading = same method name, different parameters (Python doesn't support directly; use default params/*args). Overriding = child class redefines parent's method with same name and parameters. Overriding is runtime polymorphism; overloading is compile-time (in other languages).
Abstraction
What is Abstraction?
Jaise car chalana: tum steering ghumaate ho, brake dabate ho, accelerator press karte ho. Tumhe nahi pata engine ke andar piston kaise move karta hai, fuel kaise burn hota hai, transmission kaise kaam karti hai. Ye sab abstract (chhupa hua) hai tumse. Tumhe sirf "interface" (steering, pedals) dikha hai.
Abstract Classes in Python
from abc import ABC, abstractmethod
# Abstract class - CANNOT create object of this!
# It's a template that FORCES children to implement certain methods
class Shape(ABC): # ← Must inherit from ABC
@abstractmethod # ← This decorator makes it abstract
def area(self):
pass # No implementation! Children MUST implement this.
@abstractmethod
def perimeter(self):
pass
# Non-abstract method - children inherit this as-is
def description(self):
print(f"I am a {type(self).__name__}")
# ❌ This will FAIL - can't create object of abstract class
# s = Shape() # TypeError!
# ✅ Child MUST implement ALL abstract methods
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self): # Implementing abstract method
return 3.14 * self.radius ** 2
def perimeter(self): # Implementing abstract method
return 2 * 3.14 * self.radius
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side ** 2
def perimeter(self):
return 4 * self.side
c = Circle(5)
print(c.area()) # 78.5
c.description() # I am a Circle (inherited from Shape)
s = Square(4)
print(s.perimeter()) # 16
When to Use Abstraction?
- When you want to force all children to implement certain methods
- When you have a common interface but different implementations
- When building frameworks/libraries where others will extend your code
Interface Concept
from abc import ABC, abstractmethod
# This is practically an "interface" - only abstract methods, no implementation
class PaymentGateway(ABC):
@abstractmethod
def pay(self, amount): pass
@abstractmethod
def refund(self, amount): pass
# Different payment methods implement the same interface
class UPI(PaymentGateway):
def pay(self, amount):
print(f"Paid ₹{amount} via UPI")
def refund(self, amount):
print(f"Refunded ₹{amount} to UPI")
class CreditCard(PaymentGateway):
def pay(self, amount):
print(f"Paid ₹{amount} via Credit Card")
def refund(self, amount):
print(f"Refunded ₹{amount} to Credit Card")
# You can use any payment method interchangeably
def process_payment(gateway: PaymentGateway, amount):
gateway.pay(amount)
process_payment(UPI(), 500) # Paid ₹500 via UPI
process_payment(CreditCard(), 500) # Paid ₹500 via Credit Card
A: Abstraction = hiding COMPLEXITY (showing only what's needed, hiding how it works). Encapsulation = hiding DATA (protecting internal data from direct access). Abstraction is about "what to show", encapsulation is about "what to hide."
SOLID Principles
S — Single Responsibility Principle (SRP)
Rule: One class should have only ONE job/responsibility.
class Student:
def __init__(self, name):
self.name = name
def calculate_grade(self):
...
def save_to_database(self):
... # NOT student's job!
def send_email(self):
... # NOT student's job!
class Student:
def calculate_grade(self): ...
class StudentDB:
def save(self, student): ...
class EmailService:
def send(self, to, msg): ...
# Each class = one job!
O — Open/Closed Principle (OCP)
Rule: Open for extension, closed for modification. Add new features without changing existing code.
class Discount:
def calculate(self, typ, price):
# Adding new type = modifying this!
if typ == "regular":
return price * 0.1
elif typ == "premium":
return price * 0.2
# New type? Edit this method 😬
class Discount(ABC):
@abstractmethod
def calculate(self, price): ...
class RegularDiscount(Discount):
def calculate(self, price):
return price * 0.1
class PremiumDiscount(Discount):
def calculate(self, price):
return price * 0.2
# New type? Just add new class!
L — Liskov Substitution Principle (LSP)
Rule: Child class should be usable wherever parent class is used, without breaking anything.
class Bird:
def fly(self):
print("Flying!")
class Penguin(Bird):
def fly(self):
raise Exception("Can't fly!")
# Breaks LSP! Penguin IS a Bird
# but can't be used where Bird is used
class Bird:
def move(self): ...
class FlyingBird(Bird):
def move(self):
print("Flying!")
class Penguin(Bird):
def move(self):
print("Swimming!")
# Both work as Bird. No breaking!
I — Interface Segregation Principle (ISP)
Rule: Don't force a class to implement methods it doesn't need. Break big interfaces into smaller ones.
class Worker(ABC):
@abstractmethod
def code(self): ...
@abstractmethod
def test(self): ...
@abstractmethod
def design(self): ...
class Developer(Worker):
def code(self): ...
def test(self): ...
def design(self): ...
# Developer doesn't design! 😬
class Coder(ABC):
@abstractmethod
def code(self): ...
class Tester(ABC):
@abstractmethod
def test(self): ...
class Developer(Coder, Tester):
def code(self): ...
def test(self): ...
# Only what's needed!
D — Dependency Inversion Principle (DIP)
Rule: High-level modules should depend on abstractions, not concrete classes.
class MySQLDatabase:
def save(self, data): ...
class App:
def __init__(self):
# Tightly coupled to MySQL!
self.db = MySQLDatabase()
def save_data(self, data):
self.db.save(data)
# Want to switch to PostgreSQL?
# Must change App class! 😬
class Database(ABC):
@abstractmethod
def save(self, data): ...
class MySQL(Database):
def save(self, data): ...
class PostgreSQL(Database):
def save(self, data): ...
class App:
def __init__(self, db: Database):
self.db = db # Any DB!
# Easy to switch databases!
Special / Magic / Dunder Methods
__init__, __str__). Python inhe automatically call karta hai specific situations mein. Tum inhe override karke apne objects ko powerful bana sakte ho.
class Book:
def __init__(self, title, author, pages):
"""Called when creating object: Book('...', '...', N)"""
self.title = title
self.author = author
self.pages = pages
def __str__(self):
"""Called by print() and str() - for end users"""
return f"'{self.title}' by {self.author}"
def __repr__(self):
"""Called in debugger/REPL - for developers"""
return f"Book('{self.title}', '{self.author}', {self.pages})"
def __len__(self):
"""Called by len() function"""
return self.pages
def __add__(self, other):
"""Called by + operator"""
return Book(
f"{self.title} & {other.title}",
f"{self.author} & {other.author}",
self.pages + other.pages
)
def __eq__(self, other):
"""Called by == operator"""
return self.title == other.title and self.author == other.author
def __lt__(self, other):
"""Called by < operator"""
return self.pages < other.pages
def __gt__(self, other):
"""Called by > operator"""
return self.pages > other.pages
def __del__(self):
"""Called when object is deleted/garbage collected"""
print(f"Book '{self.title}' deleted from memory")
# Usage
b1 = Book("Python Basics", "Rahul", 200)
b2 = Book("Advanced Python", "Priya", 350)
print(b1) # 'Python Basics' by Rahul ← __str__
print(repr(b1)) # Book('Python Basics', 'Rahul', 200) ← __repr__
print(len(b1)) # 200 ← __len__
b3 = b1 + b2 # ← __add__
print(b3) # 'Python Basics & Advanced Python' by Rahul & Priya
print(b1 == b2) # False ← __eq__
print(b1 < b2) # True ← __lt__ (200 < 350)
print(b1 > b2) # False ← __gt__
del b1 # Book 'Python Basics' deleted from memory ← __del__
Quick Reference Table
| Method | Triggered By | Example |
|---|---|---|
__init__ | Creating object | obj = MyClass() |
__str__ | print(), str() | print(obj) |
__repr__ | REPL, debugger | repr(obj) |
__len__ | len() | len(obj) |
__add__ | + operator | obj1 + obj2 |
__eq__ | == operator | obj1 == obj2 |
__lt__ | < operator | obj1 < obj2 |
__gt__ | > operator | obj1 > obj2 |
__del__ | del or garbage collection | del obj |
__contains__ | in keyword | x in obj |
__getitem__ | [] indexing | obj[0] |
__call__ | () calling object | obj() |
Static Methods and Class Methods
self lete hain aur specific object pe kaam karte hain). Par do aur types hain: static methods aur class methods.
@staticmethod
A static method does NOT need self or cls. It is basically a regular function that lives inside a class for organizational purposes. It cannot access or modify class or instance data.
class MathUtils:
@staticmethod
def add(a, b):
return a + b
@staticmethod
def is_even(number):
return number % 2 == 0
@staticmethod
def celsius_to_fahrenheit(celsius):
return (celsius * 9/5) + 32
# Can call WITHOUT creating an object!
print(MathUtils.add(5, 3)) # 8
print(MathUtils.is_even(4)) # True
print(MathUtils.celsius_to_fahrenheit(100)) # 212.0
@classmethod
A class method receives the class itself (cls) as first argument, not an instance. It can access/modify class-level data but not instance data.
class Student:
total_students = 0 # Class variable
def __init__(self, name):
self.name = name
Student.total_students += 1
@classmethod
def get_total(cls): # cls = the class itself (Student)
return cls.total_students
# FACTORY METHOD - creates object in a special way
@classmethod
def from_string(cls, data_string):
"""Create Student from 'name-marks' format"""
name, marks = data_string.split("-")
return cls(name) # Same as Student(name)
s1 = Student("Rahul")
s2 = Student("Priya")
s3 = Student.from_string("Amit-90") # Factory method!
print(Student.get_total()) # 3
Comparison Table
| Feature | Instance Method | @classmethod | @staticmethod |
|---|---|---|---|
| First param | self (instance) | cls (class) | None |
| Access instance data? | Yes | No | No |
| Access class data? | Yes | Yes | No |
| Need object to call? | Yes | No | No |
| Use case | Work with object data | Factory methods, class-level ops | Utility/helper functions |
- Need
self (object data)? → Instance method- Need
cls (class data, factory)? → @classmethod- Need neither? Just a helper? → @staticmethod
Composition vs Inheritance
The Difference
Composition = "HAS-A" relationship: Car HAS AN Engine.
Inheritance mein child parent ka type hai. Composition mein ek object doosre object ko "use" karta hai.
Inheritance Approach (IS-A)
# Inheritance: Car IS A Vehicle
class Vehicle:
def start(self):
print("Vehicle started")
class Car(Vehicle): # Car IS A Vehicle
def drive(self):
print("Driving...")
Composition Approach (HAS-A)
# Composition: Car HAS AN Engine, HAS Wheels
class Engine:
def __init__(self, horsepower):
self.horsepower = horsepower
self.is_running = False
def start(self):
self.is_running = True
print(f"Engine ({self.horsepower}hp) started")
def stop(self):
self.is_running = False
class GPS:
def navigate(self, destination):
print(f"Navigating to {destination}")
class Car:
def __init__(self, brand, hp):
self.brand = brand
self.engine = Engine(hp) # Car HAS an Engine
self.gps = GPS() # Car HAS a GPS
def start(self):
self.engine.start() # Delegates to engine
def go_to(self, place):
self.gps.navigate(place) # Delegates to GPS
car = Car("Maruti", 120)
car.start() # Engine (120hp) started
car.go_to("Mumbai") # Navigating to Mumbai
"Favor Composition Over Inheritance" — Why?
| Aspect | Inheritance | Composition |
|---|---|---|
| Coupling | Tight (child depends on parent internals) | Loose (uses interface only) |
| Flexibility | Cannot change parent at runtime | Can swap components at runtime |
| Complexity | Deep hierarchies get messy | Flat and simple |
| Reusability | Locked to hierarchy | Components reusable anywhere |
Common Interview Questions (Top 20)
class Car: is the blueprint; my_car = Car() is the object.self in Python? Easyself is a reference to the current instance of the class. It lets each object access its own data. When you call obj.method(), Python passes obj as self automatically.__init__? Easyself.x in __init__. Class variable: shared by all objects, defined directly in class body. Class variable changes affect all instances._protected (convention only), __private (name mangling). Use @property for getters/setters.ClassName.mro() to see the order.from abc import ABC, abstractmethod.len() works on strings, lists, dicts differently. Or multiple classes having same area() method but different calculations.super()? Mediumsuper() returns a proxy object that lets you call methods of the parent class. Commonly used in __init__ to call parent's constructor: super().__init__(args).__init__, __str__, __repr__, __len__, __add__, __eq__.__var becomes _ClassName__var) but it can still be accessed. It's a convention, not enforcement. Python follows the philosophy "we're all consenting adults here."ClassName.mro() or ClassName.__mro__.obj.x calls the getter, obj.x = val calls the setter.Quick Revision / Cheat Sheet
Class & Object
class Dog:
def __init__(self, name):
self.name = name
d = Dog("Tommy")
Inheritance
class Child(Parent):
def __init__(self):
super().__init__()
# extra stuff
Encapsulation
self.name # public
self._name # protected
self.__name # private
@property # getter
Abstraction
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self): ...
Static & Class Methods
@staticmethod
def util(): ... # no self/cls
@classmethod
def create(cls): ... # cls
Dunder Methods
__init__ # constructor
__str__ # print()
__add__ # + operator
__len__ # len()
__eq__ # == operator
Key Differences Table
| Concept A | Concept B | Key Difference |
|---|---|---|
| Class | Object | Blueprint vs actual instance |
| Instance var | Class var | Per-object vs shared by all |
| Encapsulation | Abstraction | Hiding DATA vs hiding COMPLEXITY |
| Overriding | Overloading | Same name in child vs same name, diff params |
| Inheritance | Composition | IS-A vs HAS-A |
| @staticmethod | @classmethod | No access vs class-level access |
| __str__ | __repr__ | For users vs for developers |
| Abstract class | Regular class | Cannot instantiate vs can instantiate |
| Multiple inh. | Multilevel inh. | Many parents vs chain of parents |
| Public | Private | self.x vs self.__x |
OOPs Hierarchy at a Glance
OOPs
├── Class & Object (foundation)
│ ├── __init__ (constructor)
│ ├── self (instance reference)
│ ├── Instance variables (per object)
│ └── Class variables (shared)
│
├── PILLAR 1: Encapsulation
│ ├── Public (self.x)
│ ├── Protected (self._x)
│ ├── Private (self.__x)
│ └── @property (Pythonic getters/setters)
│
├── PILLAR 2: Inheritance
│ ├── Single (one parent)
│ ├── Multiple (many parents)
│ ├── Multilevel (chain)
│ ├── Hierarchical (one parent, many children)
│ ├── Hybrid (combination)
│ ├── super() (call parent)
│ ├── MRO (method resolution order)
│ └── Method overriding
│
├── PILLAR 3: Polymorphism
│ ├── Method overriding (runtime)
│ ├── Duck typing
│ └── Operator overloading (__add__, __str__, etc.)
│
├── PILLAR 4: Abstraction
│ ├── ABC module
│ ├── @abstractmethod
│ └── Interface concept
│
├── SOLID Principles (S, O, L, I, D)
├── Magic Methods (__init__, __str__, __repr__, __len__, ...)
├── @staticmethod & @classmethod
└── Composition vs Inheritance (HAS-A vs IS-A)
1. Pehle Section 1-4 (basics) pakka karo
2. Phir 4 pillars (5-8) ek ek karke samjho
3. Phir SOLID, dunder methods, static/class methods
4. Interview questions practice karo
5. Har concept ka code khud likh ke run karo — padhne se nahi hoga, likhne se hoga!