Python OOPs Complete Guide — Zero to Hero

Section 01

Before OOPs — Procedural Programming

What is Procedural Programming?

Procedural programming matlab: tum code likhte ho ek ke baad ek step, jaise cooking recipe. Step 1 karo, step 2 karo, step 3 karo. Koi organization nahi, bas line by line code chalti jaati hai.

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?

Socho tumhare paas 2 students hain toh theek hai. Par agar 1000 students hain? Tum 1000 variables banaaoge? 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:

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:

Interview mein puchh sakte hain: "Why OOPs over Procedural?" — Answer: OOPs gives modularity (code organized in classes), reusability (inheritance), data protection (encapsulation), and flexibility (polymorphism). Procedural becomes unmanageable for large codebases.
Section 02

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

Imagine a car:
  • Data (properties): color, brand, speed, fuel level
  • Functions (behaviors): start(), drive(), brake(), honk()
In OOPs, you create a "Car" template (class) that defines all these. Then you make actual cars (objects) from it. Ek blueprint se 100 cars bana sakte ho!

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.

Ye 4 pillars yaad rakhna MANDATORY hai. Interview mein 100% puchha jaata hai: "What are the 4 pillars of OOPs?" Har ek ko detail mein samjhenge aage.

Why OOPs Matters in Interviews

Section 03

Classes and Objects

What is a Class?

Class ek blueprint hai, ek template hai. Jaise architect ka naksha (blueprint) hota hai ghar banane ke liye — wo ghar nahi hai, wo sirf plan hai ki ghar kaisa hoga. Class bhi aisi hi cheez hai — ye batata hai ki object kaisa hoga, par ye khud object nahi hai.

A class is a blueprint/template that defines:

What is an Object?

Object wo actual cheez hai jo class (blueprint) se banti hai. Blueprint se ghar banta hai, toh ghar = object. Blueprint se 10 ghar bana sakte ho — sab same design par alag alag colors, sizes mein. Har ghar ek ALAG object hai.

An object is an actual instance created from a class. It has real data and can perform real actions.

Class = Cookie Cutter (the shape/mold)
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
Rahul
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)
Beginner mistake: 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
FeatureInstance VariableClass Variable
Defined where?Inside __init__ with self.xInside class, outside any method
Unique per object?Yes (each object has its own)No (shared by all objects)
Accessobject.variableClass.variable or object.variable
Examplename, marks, ageschool, total_students

Example: BankAccount Class

Full BankAccount Example (Click to expand)
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

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
Interview mein frequently puchha jaata hai:
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.
Section 04

Methods

What Are Methods?

Methods = class ke andar likhe hue functions. Jaise car ke andar engine hai, steering hai — ye sab car ki "methods" hain (cheezein jo car KAR sakti hai). 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
Rule of thumb: If a method changes the object's data, it usually returns None (or self for chaining). If it calculates something, it returns the result.
Section 05

Encapsulation

What is Encapsulation?

Encapsulation matlab data ko chhupana aur protect karna. Bahar wale directly tumhara data change na kar sakein.

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

TypeSyntaxAccessExample
Publicself.nameAccessible from anywhereself.name = "Rahul"
Protectedself._nameConvention: don't access from outside (can still be accessed)self._salary = 50000
Privateself.__nameCannot 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

Agar data private hai toh access kaise karein? Getter (data dikhane ke liye) aur Setter (data change karne ke liye) methods banao. Isse tum control kar sakte ho ki kya data allowed hai.
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

Python mein @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!
Q: What is encapsulation?
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.
Section 06

Inheritance

What is Inheritance?

Inheritance matlab wirasat. Jaise beta apne papa ki property inherit karta hai — papa ki saari properties (ghar, paisa, zameen) bete ko mil jaati hai WITHOUT papa ko kuch extra karne ke. Aur beta apni khud ki extra cheezein bhi rakh sakta hai.

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

Diamond problem tab hota hai jab D, B aur C dono se inherit kare, aur B aur C dono A se inherit karein. Ab agar D 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)
Q: What is the difference between multiple and multilevel inheritance?
A: Multiple = one child inherits from 2+ parents (Child(Parent1, Parent2)). Multilevel = chain inheritance (Grandparent → Parent → Child). Remember: Multiple = horizontal, Multilevel = vertical.
Section 07

Polymorphism

What is Polymorphism?

Polymorphism = "ek naam, alag alag kaam".

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()}")
Circle area: 78.5
Rectangle area: 24
Triangle area: 12.0

Method Overloading (Python's Way)

Python mein method overloading directly support nahi hota (unlike Java/C++). Matlab same naam ke do methods alag parameters ke saath nahi bana sakte. Par Python mein alternatives hain: default parameters aur *args.
# 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

"If it looks like a duck, swims like a duck, and quacks like a duck, then it probably IS a duck."

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

Python mein + 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__)
Q: What is the difference between method overloading and method overriding?
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).
Section 08

Abstraction

What is Abstraction?

Abstraction = complexity chhupao, sirf zaroori cheez dikhao.

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
Socho abstract class ek contract (agreement) hai. Shape class keh rahi hai: "Jo bhi mera child banega, usse area() aur perimeter() BANANA PADEGA. Nahi banaya toh error aayega."

When to Use Abstraction?

Interface Concept

Python mein formal "interface" nahi hai (jaise Java mein hota hai). Par abstract class jisme SIRF abstract methods hain, woh practically ek interface hai.
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
Q: What is the difference between abstraction and encapsulation?
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."
Section 09

SOLID Principles

SOLID principles 5 rules hain jo tumhare code ko clean, maintainable, aur scalable banate hain. Ye interview mein puchhe jaate hain aur real-world projects mein VERY important hain.

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!
Section 10

Special / Magic / Dunder Methods

"Dunder" = Double UNDERscore. Ye special methods hain jo double underscore se start aur end hote hain (__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

MethodTriggered ByExample
__init__Creating objectobj = MyClass()
__str__print(), str()print(obj)
__repr__REPL, debuggerrepr(obj)
__len__len()len(obj)
__add__+ operatorobj1 + obj2
__eq__== operatorobj1 == obj2
__lt__< operatorobj1 < obj2
__gt__> operatorobj1 > obj2
__del__del or garbage collectiondel obj
__contains__in keywordx in obj
__getitem__[] indexingobj[0]
__call__() calling objectobj()
Section 11

Static Methods and Class Methods

Ab tak humne sirf instance methods dekhe (jo 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

FeatureInstance Method@classmethod@staticmethod
First paramself (instance)cls (class)None
Access instance data?YesNoNo
Access class data?YesYesNo
Need object to call?YesNoNo
Use caseWork with object dataFactory methods, class-level opsUtility/helper functions
When to use which?
- Need self (object data)? → Instance method
- Need cls (class data, factory)? → @classmethod
- Need neither? Just a helper? → @staticmethod
Section 12

Composition vs Inheritance

The Difference

Inheritance = "IS-A" relationship: Dog IS AN Animal.
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?

AspectInheritanceComposition
CouplingTight (child depends on parent internals)Loose (uses interface only)
FlexibilityCannot change parent at runtimeCan swap components at runtime
ComplexityDeep hierarchies get messyFlat and simple
ReusabilityLocked to hierarchyComponents reusable anywhere
Rule of thumb: Use inheritance when there is a genuine IS-A relationship (Dog IS an Animal). Use composition when one thing USES another thing (Car HAS an Engine). When in doubt, prefer composition.
Section 13

Common Interview Questions (Top 20)

Q1: What are the 4 pillars of OOPs? Easy
Encapsulation (data hiding), Inheritance (code reuse from parent), Polymorphism (same name, different behavior), Abstraction (hiding complexity). Ye char cheezein OOPs ki foundation hain.
Q2: What is the difference between a class and an object? Easy
Class is a blueprint/template. Object is an actual instance created from that class. class Car: is the blueprint; my_car = Car() is the object.
Q3: What is self in Python? Easy
self 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.
Q4: What is __init__? Easy
It's the constructor method that runs automatically when you create an object. It initializes the object's attributes.
Q5: Difference between instance variable and class variable? Medium
Instance variable: unique per object, defined with self.x in __init__. Class variable: shared by all objects, defined directly in class body. Class variable changes affect all instances.
Q6: What is encapsulation? How is it achieved in Python? Easy
Encapsulation = bundling data + methods together and restricting access. Python uses naming conventions: _protected (convention only), __private (name mangling). Use @property for getters/setters.
Q7: Difference between method overriding and method overloading? Medium
Overriding: child class redefines parent's method (same name, same params). Overloading: same method name with different parameters (Python doesn't support directly; use default params or *args).
Q8: What is the Diamond Problem? Medium
When class D inherits from B and C, both of which inherit from A. If D calls a method from A, which path does it take? Python solves this with MRO (C3 Linearization) — use ClassName.mro() to see the order.
Q9: What is an abstract class? Medium
A class that cannot be instantiated and may contain abstract methods (methods without implementation). Children MUST implement all abstract methods. Use from abc import ABC, abstractmethod.
Q10: Difference between abstraction and encapsulation? Hard
Abstraction hides COMPLEXITY (shows what, hides how). Encapsulation hides DATA (protects internal state). Abstraction = design level (abstract classes, interfaces). Encapsulation = implementation level (private variables, getters/setters).
Q11: What is polymorphism? Give an example. Easy
Same interface, different implementations. Example: len() works on strings, lists, dicts differently. Or multiple classes having same area() method but different calculations.
Q12: What is super()? Medium
super() 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).
Q13: @staticmethod vs @classmethod? Medium
@staticmethod: no self/cls, can't access instance or class data, just a utility function. @classmethod: receives cls, can access/modify class state, commonly used as factory methods.
Q14: What is duck typing? Medium
"If it walks like a duck and quacks like a duck, it's a duck." Python doesn't check types — it checks if the required method/attribute exists. This enables polymorphism without inheritance.
Q15: What are dunder methods? Name 5. Easy
Double underscore methods (magic methods) that Python calls automatically. Examples: __init__, __str__, __repr__, __len__, __add__, __eq__.
Q16: Composition vs Inheritance — when to use which? Hard
Inheritance for IS-A (Dog is Animal). Composition for HAS-A (Car has Engine). Prefer composition — it's more flexible, loosely coupled, and easier to change. Use inheritance only for genuine type hierarchies.
Q17: Explain all 5 SOLID principles briefly. Hard
Single Responsibility: one class, one job. Open/Closed: open for extension, closed for modification. Liskov Substitution: child usable wherever parent is used. Interface Segregation: no forced unused methods. Dependency Inversion: depend on abstractions, not concrete classes.
Q18: Can Python have truly private variables? Medium
No. Python uses name mangling (__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."
Q19: What is MRO? How does Python resolve it? Hard
MRO = Method Resolution Order. It determines which method is called in multiple inheritance. Python uses C3 Linearization algorithm. Check with ClassName.mro() or ClassName.__mro__.
Q20: What is the @property decorator? Medium
@property lets you define a method that can be accessed like an attribute. It provides a Pythonic way to implement getters and setters without changing the calling code. obj.x calls the getter, obj.x = val calls the setter.
Section 14

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 AConcept BKey Difference
ClassObjectBlueprint vs actual instance
Instance varClass varPer-object vs shared by all
EncapsulationAbstractionHiding DATA vs hiding COMPLEXITY
OverridingOverloadingSame name in child vs same name, diff params
InheritanceCompositionIS-A vs HAS-A
@staticmethod@classmethodNo access vs class-level access
__str____repr__For users vs for developers
Abstract classRegular classCannot instantiate vs can instantiate
Multiple inh.Multilevel inh.Many parents vs chain of parents
PublicPrivateself.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)
How to study this:
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!