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 class keyword defines a class.
  • Class names follow PascalCase (each word capitalised) by convention.
  • The class body is indented.
  • pass is 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

  • self is 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 self automatically — you do not pass it manually when calling a method.
  • The name self is 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 self as 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'>