1. Core OOP Concepts — Overview
| Concept | One-line Definition | Real-world Analogy |
|---|---|---|
| Class | A blueprint or template for creating objects | Architectural plan of a house |
| Object | A specific instance created from a class | An actual house built from the plan |
| Attribute | Data/variable stored inside an object | Number of rooms, colour of walls |
| Method | Function defined inside a class | Actions like opening the door, switching lights |
| Encapsulation | Bundling data and methods; hiding internal details | TV remote — you press buttons without knowing the circuit |
| Inheritance | A child class acquires attributes/methods of a parent class | A child inheriting traits from parents |
| Polymorphism | Same method name behaves differently in different classes | The word "draw" means different things for an artist vs. a programmer |
2. Defining a Class and Creating Objects
Class Definition Syntax
class ClassName:
# class body
def method_name(self):
# method body
pass
- The
classkeyword defines a class. - Class names follow PascalCase (each word capitalised) by convention.
- The class body is indented.
passis used as a placeholder for an empty class or method body.
Creating an Object (Instantiation)
An object is created by calling the class name as if it were a function:
class Student:
def greet(self):
print("Hello, I am a Student!")
# Creating objects (instances)
s1 = Student() # s1 is an object of class Student
s2 = Student() # s2 is another independent object
s1.greet() # Output: Hello, I am a Student!
s2.greet() # Output: Hello, I am a Student!
Note: s1 and s2 are two separate objects — changes to one do not affect the other.
The self Parameter
selfis the first parameter of every instance method in a class.- It refers to the current object on which the method is being called.
- Python passes
selfautomatically — you do not pass it manually when calling a method. - The name
selfis a convention, not a keyword — but deviating from it is strongly discouraged.
class Circle:
def area(self):
return 3.14 * self.radius * self.radius
c = Circle()
c.radius = 5
print(c.area()) # Output: 78.5
# Internally Python calls: Circle.area(c)
3. The Constructor — __init__()
The constructor is a special method called automatically when an object is created. In Python, the constructor is __init__().
- Used to initialise instance attributes when the object is first created.
- Always takes
selfas its first argument. - Does NOT return any value (implicitly returns
None). - If not defined, Python provides a default empty constructor.
class Student:
def __init__(self, name, roll, marks):
self.name = name # instance attribute
self.roll = roll
self.marks = marks
def display(self):
print(f"Name: {self.name}, Roll: {self.roll}, Marks: {self.marks}")
def grade(self):
if self.marks >= 90:
return 'A'
elif self.marks >= 75:
return 'B'
else:
return 'C'
# Creating objects — __init__ is called automatically
s1 = Student("Aarav", 101, 92)
s2 = Student("Priya", 102, 78)
s1.display() # Name: Aarav, Roll: 101, Marks: 92
s2.display() # Name: Priya, Roll: 102, Marks: 78
print(s1.grade()) # A
print(s2.grade()) # B
Instance Attributes vs Class Attributes
| Feature | Instance Attribute | Class Attribute |
|---|---|---|
| Defined inside | __init__() using self.name |
Directly inside the class body |
| Belongs to | Each specific object (instance) | The class itself — shared by ALL objects |
| Access | object.attribute |
ClassName.attribute or object.attribute |
| Example | self.name = "Aarav" |
school = "DPS" (inside class) |
class Student:
school = "DPS" # class attribute — shared by all Student objects
def __init__(self, name):
self.name = name # instance attribute — unique per object
s1 = Student("Aarav")
s2 = Student("Priya")
print(Student.school) # DPS (accessed via class name)
print(s1.school) # DPS (accessed via object — looks up class)
print(s1.name) # Aarav
print(s2.name) # Priya
4. Types of Methods
| Method Type | First Parameter | Decorator | Accesses |
|---|---|---|---|
| Instance Method | self |
None | Instance attributes and class attributes |
| Class Method | cls |
@classmethod |
Class attributes only (not instance) |
| Static Method | None (no self or cls) |
@staticmethod |
Neither — utility function inside class |
class MathHelper:
pi = 3.14159 # class attribute
def __init__(self, value):
self.value = value # instance attribute
def square(self): # instance method
return self.value ** 2
@classmethod
def get_pi(cls): # class method
return cls.pi
@staticmethod
def add(a, b): # static method
return a + b
m = MathHelper(5)
print(m.square()) # 25
print(MathHelper.get_pi()) # 3.14159
print(MathHelper.add(3,4)) # 7
5. Encapsulation and Data Hiding
Encapsulation is the bundling of data (attributes) and methods that operate on the data within a single unit (class), while restricting direct access from outside. This is achieved using access modifiers:
| Access Modifier | Syntax | Accessible From | Example |
|---|---|---|---|
| Public | name |
Anywhere | self.name |
| Protected | _name (single underscore) |
Class and subclasses (by convention) | self._salary |
| Private | __name (double underscore) |
Within the class only (name mangled) | self.__password |
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner # public
self._branch = "Main" # protected
self.__balance = balance # private — name mangled to _BankAccount__balance
def deposit(self, amount):
if amount > 0:
self.__balance += amount
def get_balance(self): # getter method — controlled access
return self.__balance
acc = BankAccount("Aarav", 10000)
print(acc.owner) # Aarav (public — works fine)
print(acc._branch) # Main (protected — works but not recommended)
# print(acc.__balance) # AttributeError — private, cannot access directly
print(acc.get_balance()) # 10000 (accessed through getter method)
Name Mangling: Python transforms __attr to _ClassName__attr internally. So acc.__balance fails but acc._BankAccount__balance technically works — though using it defeats the purpose of privacy.
6. Inheritance
Inheritance allows a class (child/derived class) to acquire the attributes and methods of another class (parent/base class). It promotes code reusability.
class ParentClass:
# parent body
class ChildClass(ParentClass):
# child body — inherits all public/protected members of Parent
Types of Inheritance in Python
| Type | Description | Syntax |
|---|---|---|
| Single | One child, one parent | class B(A): |
| Multiple | One child, multiple parents | class C(A, B): |
| Multilevel | Chain: A → B → C | class B(A): class C(B): |
| Hierarchical | Multiple children from one parent | class B(A): class C(A): |
| Hybrid | Combination of two or more types | Mix of the above |
Single Inheritance — Full Example
class Animal: # Parent class
def __init__(self, name):
self.name = name
def speak(self):
print(f"{self.name} makes a sound.")
def breathe(self):
print(f"{self.name} breathes air.")
class Dog(Animal): # Child class — inherits Animal
def __init__(self, name, breed):
super().__init__(name) # call parent __init__
self.breed = breed
def speak(self): # method overriding
print(f"{self.name} barks!")
d = Dog("Bruno", "Labrador")
d.speak() # Bruno barks! (overridden method)
d.breathe() # Bruno breathes air. (inherited method)
print(d.name) # Bruno
print(d.breed) # Labrador
The super() Function
super() returns a proxy object of the parent class, allowing the child class to call the parent's methods. It is most commonly used to call the parent's __init__() to ensure proper initialisation of inherited attributes.
class Vehicle:
def __init__(self, brand, speed):
self.brand = brand
self.speed = speed
class Car(Vehicle):
def __init__(self, brand, speed, fuel):
super().__init__(brand, speed) # initialise parent's attributes
self.fuel = fuel # additional child attribute
def describe(self):
print(f"{self.brand} | Speed: {self.speed} km/h | Fuel: {self.fuel}")
c = Car("Toyota", 180, "Petrol")
c.describe() # Toyota | Speed: 180 km/h | Fuel: Petrol
Multiple Inheritance
class Father:
def skills(self):
print("Cooking, Gardening")
class Mother:
def skills(self):
print("Painting, Singing")
class Child(Father, Mother): # inherits from both
def own_skills(self):
print("Coding, Gaming")
c = Child()
c.own_skills() # Coding, Gaming
c.skills() # Cooking, Gardening
# Python uses MRO — Father is checked before Mother
MRO (Method Resolution Order): When a method is called, Python searches classes in a specific order defined by the C3 Linearisation Algorithm. Use ClassName.mro() or ClassName.__mro__ to view the order.
7. Method Overriding
Method overriding occurs when a child class defines a method with the same name as a method in the parent class. The child's version replaces the parent's version for objects of the child class.
class Shape:
def area(self):
print("Area of a generic shape.")
class Rectangle(Shape):
def __init__(self, l, b):
self.l = l
self.b = b
def area(self): # overrides Shape.area()
print(f"Area = {self.l * self.b}")
class Circle(Shape):
def __init__(self, r):
self.r = r
def area(self): # overrides Shape.area()
print(f"Area = {3.14 * self.r ** 2}")
# Polymorphic behaviour
shapes = [Rectangle(4, 5), Circle(7)]
for s in shapes:
s.area()
# Output:
# Area = 20
# Area = 153.86
The ability to call the same method (area()) on different objects and get different results is polymorphism. The correct method is determined at runtime based on the object's type — this is called dynamic dispatch.
8. Special (Dunder) Methods
Python provides special methods (also called dunder/magic methods — double underscore on both sides) that allow classes to support built-in operations. They are called automatically by Python when specific operations are performed.
| Method | Triggered When | Purpose |
|---|---|---|
__init__(self, ...) |
Object created | Initialise attributes (constructor) |
__str__(self) |
print(obj) or str(obj) |
Human-readable string representation |
__repr__(self) |
repr(obj) |
Developer-readable string (unambiguous) |
__len__(self) |
len(obj) |
Return length of object |
__add__(self, other) |
obj1 + obj2 |
Operator overloading for + |
__eq__(self, other) |
obj1 == obj2 |
Operator overloading for == |
__del__(self) |
Object deleted / garbage collected | Destructor — cleanup code |
class Book:
def __init__(self, title, pages):
self.title = title
self.pages = pages
def __str__(self):
return f"'{self.title}' ({self.pages} pages)"
def __len__(self):
return self.pages
def __add__(self, other):
# Combining two books returns total pages
return self.pages + other.pages
b1 = Book("Python Essentials", 350)
b2 = Book("Data Structures", 280)
print(b1) # 'Python Essentials' (350 pages)
print(len(b1)) # 350
print(b1 + b2) # 630
This is called operator overloading — giving custom meaning to built-in operators for user-defined objects.
9. Built-in Functions for OOP
| Function | Purpose | Example | Output |
|---|---|---|---|
isinstance(obj, cls) |
Check if obj is an instance of cls | isinstance(d, Dog) |
True |
issubclass(cls1, cls2) |
Check if cls1 is a subclass of cls2 | issubclass(Dog, Animal) |
True |
hasattr(obj, name) |
Check if object has an attribute | hasattr(s1, 'name') |
True |
getattr(obj, name) |
Get value of an attribute by name string | getattr(s1, 'name') |
'Aarav' |
setattr(obj, name, val) |
Set value of an attribute dynamically | setattr(s1,'name','Riya') |
s1.name = 'Riya' |
type(obj) |
Return the class/type of object | type(s1) |
<class 'Student'> |

