Deep Dive into Object-Oriented Programming (OOP) with Python

Deep Dive into Object-Oriented Programming (OOP) with Python

Index

  • Introduction to OOP in Python

  • Procedural vs Object-Oriented Programming

  • Classes and Objects in Python

  • Constructors (__init__ Method)

  • Instance Variables and Methods

  • Class Variables and Methods (@classmethod)

  • Static Methods (@staticmethod)

  • Inheritance in Python

    • Single Inheritance

    • Multiple Inheritance

    • Multilevel Inheritance

    • Hierarchical Inheritance

    • Hybrid Inheritance

  • Method Overriding

  • Multiple Inheritance and MRO (Method Resolution Order)

  • Polymorphism

  • Encapsulation

  • Data Hiding (__private variables)

  • Abstraction (ABC module, @abstractmethod)

  • Magic (Dunder) Methods (__str__, __repr__, __len__, etc.)

  • Operator Overloading

  • MRO(Method Resolution Order )

Introduction: Mastering Object-Oriented Programming in Python

Object-Oriented Programming (OOP) is a fundamental paradigm in modern software development, allowing us to structure code in a modular, reusable, and scalable way. Python, being a versatile and beginner-friendly language, provides powerful support for OOP through classes, objects, inheritance, polymorphism, and more.

In this deep dive, we’ll explore OOP concepts in Python from the ground up. Whether you're a beginner looking to solidify your understanding or an experienced developer wanting to refine your skills, this series will provide practical insights, examples, and best practices. By the end, you’ll be able to write clean, efficient, and well-structured object-oriented code in Python.

Let’s get started! 🚀

What is an Object-Oriented Programming (OOP) Language?

An Object-Oriented Programming (OOP) language is a programming language that is based on the concept of objects. Objects are instances of classes that encapsulate data (attributes) and behaviors (methods) in a structured way. OOP is designed to make code more modular, reusable, and scalable by organizing it into self-contained objects that interact with each other.

Key Features of OOP Languages

  1. Classes and Objects – A class is a blueprint, and an object is an instance of a class.

  2. Encapsulation – Data and methods are bundled together, restricting direct access to some details.

  3. Inheritance – A class can inherit properties and behaviors from another class.

  4. Polymorphism – Objects can take multiple forms, allowing flexibility in coding.

  5. Abstraction – Hides complex implementation details and exposes only the necessary parts.

Examples of OOP Languages

  • Python 🐍

  • Java

  • C++

  • C#

  • Ruby

  • Swift

What is a Procedural Programming Language?

A Procedural Programming Language is a type of programming language that follows a step-by-step approach, where the code is written as a sequence of instructions (or procedures) that the computer executes in order. It is based on the concept of procedures (functions), which group statements into reusable blocks.

Characteristics of Procedural Programming:

  1. Top-Down Approach – Programs are structured in a linear flow from start to finish.

  2. Uses Functions – Code is divided into reusable functions to avoid repetition.

  3. Global and Local Variables – Data is stored in variables, which can be accessed by different parts of the program.

  4. Less Emphasis on Data Security – Since data is often stored in global variables, it can be accessed and modified from anywhere in the program.

Examples of Procedural Programming Languages:

  • C

  • Pascal

  • Fortran

  • BASIC


Problems with Procedural Programming:

  1. Code Complexity Increases for Large Projects

    • As projects grow, procedural programming can lead to spaghetti code, where functions become highly interdependent and difficult to maintain.
  2. Lack of Data Security (Encapsulation)

    • Data is often stored in global variables, which can be accessed and modified from anywhere, making the program prone to errors and security vulnerabilities.
  3. Code Reusability is Limited

    • Functions can be reused, but without objects and inheritance, it is harder to create reusable components compared to OOP.
  4. Difficult to Model Real-World Scenarios

    • Since procedural programming does not use objects, representing real-world entities (e.g., a Car, Person, or Bank Account) is not as intuitive as in OOP.
  5. Harder to Scale and Modify

    • Adding new features or modifying existing ones can be challenging because changes in one part of the program may require changes in multiple other places.

Why OOP is Preferred Over Procedural Programming?

While procedural programming works well for small and simple programs, OOP is preferred for large, complex, and scalable applications. OOP allows for modularity, code reuse, better data security, and easier maintenance, making it ideal for modern software development.

Procedural Approach

In a procedural style, we use functions and global variables to manage the account.

# Procedural Approach

# Global variables
account_balance = 0

# Function to deposit money
def deposit(amount):
    global account_balance
    account_balance += amount
    print(f"Deposited ${amount}. New Balance: ${account_balance}")

# Function to withdraw money
def withdraw(amount):
    global account_balance
    if amount > account_balance:
        print("Insufficient funds!")
    else:
        account_balance -= amount
        print(f"Withdrew ${amount}. New Balance: ${account_balance}")

# Function to check balance
def check_balance():
    print(f"Current Balance: ${account_balance}")

# Using the functions
deposit(1000)
withdraw(500)
check_balance()

Problems with Procedural Approach:

  1. Uses global variables, making the data less secure.

  2. Not scalable—if we need multiple accounts, we must create separate variables for each.

  3. Code repetition—functions have to repeatedly reference global variables.

Object-Oriented Approach

In OOP, we use a class to create multiple bank accounts with encapsulated data.

# Object-Oriented Approach

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner  # Account holder name
        self.balance = balance  # Initial balance

    def deposit(self, amount):
        self.balance += amount
        print(f"{self.owner} deposited ${amount}. New Balance: ${self.balance}")

    def withdraw(self, amount):
        if amount > self.balance:
            print(f"{self.owner}, insufficient funds!")
        else:
            self.balance -= amount
            print(f"{self.owner} withdrew ${amount}. New Balance: ${self.balance}")

    def check_balance(self):
        print(f"{self.owner}'s Balance: ${self.balance}")

# Creating multiple account objects
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 500)

# Performing transactions
account1.deposit(500)
account1.withdraw(200)
account1.check_balance()

account2.deposit(300)
account2.withdraw(1000)  # Insufficient funds
account2.check_balance()

Benefits of the OOP Approach:

Encapsulation – Each object (account) stores its own data securely.
Scalability – We can create multiple accounts without modifying the core code.
Code Reusability – The BankAccount class can be reused for any number of accounts.
Better Organization – Code is more structured, making it easier to maintain.

Classes and Objects in Python

Python is an object-oriented programming (OOP) language, meaning it revolves around the concept of classes and objects.


What is a Class?

A class is a blueprint for creating objects. It defines the attributes (data/variables) and methods (functions) that its objects will have.

Think of a class as a template for making objects. For example, a Car class might define attributes like color, brand, speed and methods like accelerate() and brake().

Example of a Class in Python

# Defining a class
class Car:
    def __init__(self, brand, color):  # Constructor method
        self.brand = brand  # Attribute
        self.color = color  # Attribute

    def drive(self):  # Method
        print(f"The {self.color} {self.brand} is driving.")

# Creating objects (instances) of the Car class
car1 = Car("Tesla", "Red")
car2 = Car("BMW", "Blue")

# Calling methods
car1.drive()  # Output: The Red Tesla is driving.
car2.drive()  # Output: The Blue BMW is driving.

What is an Object?

An object is an instance of a class. It is a real-world entity that has the properties and behaviors defined in the class.

For example:

  • car1 = Car("Tesla", "Red") → Creates an object of the Car class with brand=Tesla and color=Red

  • car2 = Car("BMW", "Blue") → Another object with different attributes


Understanding the __init__ Method

The __init__ method is a constructor that is called automatically when an object is created. It is used to initialize attributes.

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def greet(self):
        print(f"Hello, my name is {self.name} and I am {self.age} years old.")

# Creating objects
person1 = Person("Alice", 25)
person2 = Person("Bob", 30)

# Calling method
person1.greet()  # Output: Hello, my name is Alice and I am 25 years old.
person2.greet()  # Output: Hello, my name is Bob and I am 30 years old.

Key Differences Between a Class and an Object

FeatureClassObject
DefinitionA blueprint for creating objectsAn instance of a class
UsageDefines attributes and methodsStores actual data and behaviors
ExampleCar class defines brand, color, and drive methodcar1 = Car("Tesla", "Red") is an object

Why Use Classes and Objects?

Code Reusability – Define once, create multiple objects
Encapsulation – Protects data using private attributes
Scalability – Makes large programs more manageable
Real-World Modeling – Helps represent real-world entities

Constructors in Python (__init__ Method)

A constructor is a special method in a class that is automatically called when an object is created. In Python, the constructor is defined using the __init__ method.

The main purpose of a constructor is to initialize an object's attributes when it is created.


Syntax of __init__ Constructor

class ClassName:
    def __init__(self, parameter1, parameter2):
        self.attribute1 = parameter1
        self.attribute2 = parameter2

Example: Understanding __init__ Method

class Person:
    def __init__(self, name, age):  # Constructor
        self.name = name  # Initializing attributes
        self.age = age

    def greet(self):  # Method
        print(f"Hello, my name is {self.name} and I am {self.age} years old.")

# Creating objects
person1 = Person("Alice", 25)
person2 = Person("Bob", 30)

# Calling method
person1.greet()  # Output: Hello, my name is Alice and I am 25 years old.
person2.greet()  # Output: Hello, my name is Bob and I am 30 years old.

How __init__ Works Here?

  1. When person1 = Person("Alice", 25) is executed:

    • The __init__ method runs automatically.

    • self.name is set to "Alice", and self.age is set to 25.

    • A new object person1 is created with these values.

  2. Similarly, person2 is created with different values.

Types of Constructors in Python

Python supports three types of constructors:

1️⃣ Default Constructor (No Parameters)

A constructor without parameters, except self.

class Example:
    def __init__(self):  # Default constructor
        print("Default Constructor Called!")

# Creating an object
obj = Example()  # Output: Default Constructor Called!

2️⃣ Parameterized Constructor (With Arguments)

A constructor that takes parameters to initialize object attributes.

class Car:
    def __init__(self, brand, color):
        self.brand = brand
        self.color = color

    def display(self):
        print(f"Car Brand: {self.brand}, Color: {self.color}")

# Creating objects with parameters
car1 = Car("Tesla", "Red")
car2 = Car("BMW", "Blue")

car1.display()  # Output: Car Brand: Tesla, Color: Red
car2.display()  # Output: Car Brand: BMW, Color: Blue

3️⃣ Constructor with Default Values

A constructor where some parameters have default values.

class Student:
    def __init__(self, name, grade="Not Assigned"):  # Default value
        self.name = name
        self.grade = grade

    def display(self):
        print(f"Student: {self.name}, Grade: {self.grade}")

# Creating objects with and without the second parameter
student1 = Student("Alice", "A")
student2 = Student("Bob")  # Uses default grade

student1.display()  # Output: Student: Alice, Grade: A
student2.display()  # Output: Student: Bob, Grade: Not Assigned

Key Points About Constructors (__init__)

✅ Automatically called when an object is created.
✅ Used to initialize attributes of the class.
✅ Can have default values for parameters.
✅ If not defined, Python provides a default constructor (empty constructor).

Instance Variables and Methods in Python

In Python's Object-Oriented Programming (OOP), instance variables and instance methods define the behavior and characteristics of individual objects.


Instance Variables

What are Instance Variables?

Instance variables are unique to each object and hold data that belongs to that specific instance.
✅ They are defined inside the __init__ method using self.variable_name.
✅ Each object gets its own copy of instance variables.

Example of Instance Variables:

class Person:
    def __init__(self, name, age):
        self.name = name  # Instance variable
        self.age = age    # Instance variable

# Creating objects
person1 = Person("Alice", 25)
person2 = Person("Bob", 30)

# Accessing instance variables
print(person1.name, person1.age)  # Output: Alice 25
print(person2.name, person2.age)  # Output: Bob 30

🔹 Each object has its own name and age variables.


Instance Methods

What are Instance Methods?

Instance methods operate on instance variables and can access or modify object-specific data.
✅ They must include self as the first parameter to refer to the current object.
✅ They are called using an object of the class.

Example of Instance Methods:

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def greet(self):  # Instance method
        print(f"Hello, my name is {self.name} and I am {self.age} years old.")

# Creating an object
person1 = Person("Alice", 25)
person1.greet()  # Output: Hello, my name is Alice and I am 25 years old.

🔹 The greet() method uses self.name and self.age, so it works for each object separately.


Modifying Instance Variables Using Methods

Instance methods can update or modify instance variables.

class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):  # Instance method
        self.balance += amount
        print(f"{self.owner} deposited ${amount}. New Balance: ${self.balance}")

    def withdraw(self, amount):  # Instance method
        if amount > self.balance:
            print(f"Insufficient funds for {self.owner}!")
        else:
            self.balance -= amount
            print(f"{self.owner} withdrew ${amount}. New Balance: ${self.balance}")

# Creating objects
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 500)

# Calling instance methods
account1.deposit(500)   # Output: Alice deposited $500. New Balance: $1500
account1.withdraw(200)  # Output: Alice withdrew $200. New Balance: $1300

account2.withdraw(600)  # Output: Insufficient funds for Bob!

Key Differences Between Instance Variables & Methods

FeatureInstance VariableInstance Method
DefinitionStores object-specific dataPerforms actions on instance variables
Where Defined?Inside __init__ methodInside the class, has self parameter
ScopeBelongs to a specific objectCan access and modify instance variables
Accessed Byobject.variable_nameobject.method_name()
Exampleself.name = "Alice"def greet(self): print(self.name)

Class Variables and Class Methods (@classmethod) in Python

In Python's Object-Oriented Programming (OOP), class variables and class methods are used when we want to define data and behavior that should be shared across all instances of a class.


Class Variables

What are Class Variables?

Class variables are shared across all instances of a class.
✅ They are defined outside the __init__ method, inside the class.
✅ Changing a class variable affects all instances of the class.


Example of Class Variables

class Employee:
    company = "TechCorp"  # Class variable (shared across all objects)

    def __init__(self, name, salary):
        self.name = name  # Instance variable (unique to each object)
        self.salary = salary  # Instance variable

    def display(self):
        print(f"Employee: {self.name}, Salary: {self.salary}, Company: {Employee.company}")

# Creating objects
emp1 = Employee("Alice", 50000)
emp2 = Employee("Bob", 60000)

# Accessing class and instance variables
emp1.display()  # Output: Employee: Alice, Salary: 50000, Company: TechCorp
emp2.display()  # Output: Employee: Bob, Salary: 60000, Company: TechCorp

# Modifying the class variable
Employee.company = "CodeLabs"

emp1.display()  # Output: Employee: Alice, Salary: 50000, Company: CodeLabs
emp2.display()  # Output: Employee: Bob, Salary: 60000, Company: CodeLabs

🔹 Key Takeaways on Class Variables:

  • Shared by all instances of the class.

  • Can be accessed using ClassName.variable_name.

  • Changing the class variable affects all instances.


Class Methods (@classmethod)

What are Class Methods?

✅ Class methods operate on class variables and work at the class level, not at the instance level.
✅ They do not modify instance variables, but can modify class variables.
✅ They take cls as the first parameter instead of self.
✅ They are defined using the @classmethod decorator.


Example of Class Methods

class Employee:
    company = "TechCorp"  # Class variable

    def __init__(self, name, salary):
        self.name = name  # Instance variable
        self.salary = salary  # Instance variable

    @classmethod
    def change_company(cls, new_company):
        cls.company = new_company  # Modifying class variable

    def display(self):
        print(f"Employee: {self.name}, Salary: {self.salary}, Company: {Employee.company}")

# Creating objects
emp1 = Employee("Alice", 50000)
emp2 = Employee("Bob", 60000)

emp1.display()  # Output: Employee: Alice, Salary: 50000, Company: TechCorp
emp2.display()  # Output: Employee: Bob, Salary: 60000, Company: TechCorp

# Using class method to change the class variable
Employee.change_company("CodeLabs")

emp1.display()  # Output: Employee: Alice, Salary: 50000, Company: CodeLabs
emp2.display()  # Output: Employee: Bob, Salary: 60000, Company: CodeLabs

How @classmethod Works Here?

  • change_company(cls, new_company) modifies the company class variable.

  • Calling Employee.change_company("CodeLabs") changes company for all objects.

  • Unlike instance methods, class methods don't use self, because they don't operate on instance variables.


Difference Between Instance & Class Variables & Methods

FeatureInstance VariableClass Variable
DefinitionDefined inside __init__Defined inside class but outside __init__
Belongs ToSpecific object (instance)Class (shared across all objects)
Modified ByInstance methodsClass methods
Accessed Byself.variable_nameClassName.variable_name
FeatureInstance MethodClass Method
DefinitionDefined with self parameterDefined with @classmethod and cls parameter
Works OnInstance variablesClass variables
Called ByObject (obj.method())Class (ClassName.method())
Exampledef display(self):@classmethod def update(cls):

Static Methods (@staticmethod) in Python

A static method is a method inside a class that does not depend on instance (self) or class (cls) variables. It behaves like a regular function but is logically grouped inside a class for better organization.


Key Features of Static Methods

✅ Defined using the @staticmethod decorator.
✅ Does not use self (instance reference) or cls (class reference).
✅ Can be called using either the class name or an instance.
✅ Used when a function logically belongs to the class but does not need access to class or instance attributes.


🔹 Syntax of @staticmethod

class ClassName:
    @staticmethod
    def method_name(parameters):
        # Code here

Example of a Static Method

class MathOperations:
    @staticmethod
    def add(x, y):
        return x + y

    @staticmethod
    def multiply(x, y):
        return x * y

# Calling static methods using the class name
print(MathOperations.add(5, 3))      # Output: 8
print(MathOperations.multiply(4, 2)) # Output: 8

# Calling static methods using an instance
math_obj = MathOperations()
print(math_obj.add(10, 5))  # Output: 15

add() and multiply() are static methods because they don’t modify or access instance/class attributes.


When to Use Static Methods?

1️⃣ Utility Functions Inside a Class

Static methods are useful when you need a function that relates to the class but doesn’t use class or instance data.

import datetime

class DateUtils:
    @staticmethod
    def is_weekend(day):
        return day.weekday() >= 5  # 5 = Saturday, 6 = Sunday

# Usage
today = datetime.date.today()
print(DateUtils.is_weekend(today))  # Output: True or False based on the day

is_weekend() checks if a given date is a weekend but does not modify class or instance attributes.


2️⃣ Avoiding Unnecessary Object Creation

Sometimes, you don’t need an instance but still need to call a method.

class Validator:
    @staticmethod
    def is_positive(number):
        return number > 0

# Calling without creating an object
print(Validator.is_positive(10))  # Output: True
print(Validator.is_positive(-5))  # Output: False

is_positive() is a self-contained function that logically belongs to Validator, so it’s defined as a static method.


Inheritance in Python (OOP)

Inheritance is one of the fundamental principles of Object-Oriented Programming (OOP). It allows a new class (child class) to derive properties and behaviors from an existing class (parent class), enabling code reuse and hierarchical relationships.


🔹 Why Use Inheritance?

✅ Promotes code reuse (avoid redundant code).
✅ Establishes a parent-child relationship between classes.
✅ Allows method overriding to customize behavior in child classes.
✅ Supports polymorphism (same method, different implementations).


🔹 Basic Syntax of Inheritance

class ParentClass:
    # Parent class attributes and methods
    pass

class ChildClass(ParentClass):
    # Inherits from ParentClass
    pass

👆 The child class (ChildClass) inherits all attributes and methods from the parent class (ParentClass).


🔹 Example of Inheritance

# Parent class
class Animal:
    def __init__(self, name):
        self.name = name

    def make_sound(self):
        return "Some generic animal sound"

# Child class inheriting from Animal
class Dog(Animal):
    def make_sound(self):  # Method Overriding
        return "Bark!"

# Creating objects
animal = Animal("Generic Animal")
dog = Dog("Buddy")

print(animal.name, "says:", animal.make_sound())  # Output: Generic Animal says: Some generic animal sound
print(dog.name, "says:", dog.make_sound())        # Output: Buddy says: Bark!

The Dog class inherits the name attribute from Animal but overrides make_sound().


🔹 Types of Inheritance in Python

1️⃣ Single Inheritance

A child class inherits from one parent class.

class Parent:
    def display(self):
        print("This is Parent class")

class Child(Parent):
    pass  # Inherits everything from Parent

obj = Child()
obj.display()  # Output: This is Parent class

2️⃣ Multiple Inheritance

A child class inherits from multiple parent classes.

class Father:
    def skill(self):
        print("Father: Knows carpentry")

class Mother:
    def skill(self):
        print("Mother: Knows painting")

class Child(Father, Mother):
    pass

obj = Child()
obj.skill()  # Output: Father: Knows carpentry (Follows Method Resolution Order - MRO)

Python follows Method Resolution Order (MRO), so Father.skill() is called first.

3️⃣ Multilevel Inheritance

A class inherits from another class, which in turn inherits from another class, forming a chain.

class Grandparent:
    def family_name(self):
        print("Family name: Smith")

class Parent(Grandparent):
    def parent_info(self):
        print("Parent: Engineer")

class Child(Parent):
    def child_info(self):
        print("Child: Student")

obj = Child()
obj.family_name()  # ✅ Output: Family name: Smith
obj.parent_info()  # ✅ Output: Parent: Engineer
obj.child_info()   # ✅ Output: Child: Student

Child inherits from Parent, and Parent inherits from Grandparent.


4️⃣ Hierarchical Inheritance

Multiple child classes inherit from the same parent class.

class Vehicle:
    def vehicle_info(self):
        print("This is a vehicle")

class Car(Vehicle):
    def car_info(self):
        print("This is a car")

class Bike(Vehicle):
    def bike_info(self):
        print("This is a bike")

car = Car()
bike = Bike()

car.vehicle_info()  # ✅ Output: This is a vehicle
car.car_info()      # ✅ Output: This is a car

bike.vehicle_info() # ✅ Output: This is a vehicle
bike.bike_info()    # ✅ Output: This is a bike

Both Car and Bike inherit vehicle_info() from Vehicle.


5️⃣ Hybrid Inheritance

A combination of multiple inheritance types.

class A:
    def method_A(self):
        print("Class A")

class B(A):
    def method_B(self):
        print("Class B")

class C(A):
    def method_C(self):
        print("Class C")

class D(B, C):
    def method_D(self):
        print("Class D")

obj = D()
obj.method_A()  # ✅ Inherited from A
obj.method_B()  # ✅ Inherited from B
obj.method_C()  # ✅ Inherited from C
obj.method_D()  # ✅ Defined in D

This structure is a mix of multiple and hierarchical inheritance.


Method Resolution Order (MRO) in Inheritance

Python uses the C3 Linearization Algorithm (MRO) to determine the method execution order in multiple inheritance.

You can check the MRO of a class using:

print(D.__mro__)  # OR
print(D.mro())

Using super() in Inheritance

The super() function allows calling methods from the parent class inside the child class.

class Parent:
    def show(self):
        print("This is the parent class")

class Child(Parent):
    def show(self):
        super().show()  # Call Parent's show()
        print("This is the child class")

obj = Child()
obj.show()
# ✅ Output:
# This is the parent class
# This is the child class

Method Overriding in Python (OOP)

Method Overriding allows a child class to redefine a method from its parent class with a new implementation. It is a key feature of polymorphism in Object-Oriented Programming (OOP).


Why Use Method Overriding?

✅ Allows customization of inherited methods.
✅ Implements polymorphism (same method name, different behavior).
✅ Enhances code reuse and modularity.


Method Overriding Syntax

class Parent:
    def show(self):
        print("This is the parent class method.")

class Child(Parent):
    def show(self):  # Overriding the parent method
        print("This is the child class method.")

# Creating objects
obj = Child()
obj.show()  # Output: This is the child class method.

✅ The child class overrides the show() method from the parent class.


Example: Overriding a Parent Class Method

class Animal:
    def make_sound(self):
        return "Some generic animal sound"

class Dog(Animal):
    def make_sound(self):  # Overriding method
        return "Bark!"

class Cat(Animal):
    def make_sound(self):  # Overriding method
        return "Meow!"

# Creating objects
dog = Dog()
cat = Cat()

print(dog.make_sound())  # Output: Bark!
print(cat.make_sound())  # Output: Meow!

✅ The make_sound() method is overridden in both Dog and Cat classes, each providing its own behavior.


Using super() to Call Parent Class Method

The super() function is used to call the parent class method inside the overridden method.

class Parent:
    def show(self):
        print("This is the parent class method.")

class Child(Parent):
    def show(self):
        super().show()  # Calls Parent's show() method
        print("This is the child class method.")

# Creating object
obj = Child()
obj.show()

🔹 Output:

kotlinCopyEditThis is the parent class method.
This is the child class method.

super().show() ensures the parent method is executed before the overridden method in the child class.


Overriding __init__() (Constructor Overriding)

A child class can override the constructor (__init__()) of a parent class.

class Parent:
    def __init__(self, name):
        self.name = name
        print(f"Parent Constructor: {self.name}")

class Child(Parent):
    def __init__(self, name, age):
        super().__init__(name)  # Calling Parent's constructor
        self.age = age
        print(f"Child Constructor: {self.name}, Age: {self.age}")

# Creating object
obj = Child("Alice", 25)

🔹 Output:

Parent Constructor: Alice
Child Constructor: Alice, Age: 25

The child class overrides __init__() but still calls the parent constructor using super().


Multiple Inheritance in Python (OOP)

Multiple Inheritance allows a class to inherit from more than one parent class. This means the child class can access attributes and methods from multiple base classes, combining functionalities.


Why Use Multiple Inheritance?

✅ Allows a class to reuse code from multiple parent classes.
✅ Helps in building complex relationships between classes.
✅ A child class can combine behaviors from multiple parents.


Syntax of Multiple Inheritance

class Parent1:
    def method1(self):
        print("Method from Parent1")

class Parent2:
    def method2(self):
        print("Method from Parent2")

class Child(Parent1, Parent2):  # Inheriting from both Parent1 and Parent2
    pass

# Creating an object
obj = Child()
obj.method1()  # Output: Method from Parent1
obj.method2()  # Output: Method from Parent2

✅ The Child class inherits methods from both parent classes.


Example of Multiple Inheritance

class Father:
    def skill(self):
        print("Father: Knows Carpentry")

class Mother:
    def skill(self):
        print("Mother: Knows Painting")

class Child(Father, Mother):
    pass

# Creating an object of Child
obj = Child()
obj.skill()  # Output: Father: Knows Carpentry

Why does it call Father.skill() first?
Python follows the Method Resolution Order (MRO), which searches methods from left to right in the class definition (FatherMother).


Handling Method Conflicts with super()

If both parent classes have a method with the same name, the method from the first parent in the inheritance list is called.

class Parent1:
    def show(self):
        print("This is Parent1")

class Parent2:
    def show(self):
        print("This is Parent2")

class Child(Parent1, Parent2):
    def show(self):
        super().show()  # Calls Parent1's method

obj = Child()
obj.show()  # Output: This is Parent1

Using super() ensures proper method resolution.

The Diamond Problem in Multiple Inheritance

The diamond problem occurs when a class inherits from two classes that have a common parent.

class A:
    def show(self):
        print("This is A")

class B(A):
    def show(self):
        print("This is B")

class C(A):
    def show(self):
        print("This is C")

class D(B, C):  # Inheriting from both B and C, which inherit from A
    pass

obj = D()
obj.show()  # Output: This is B (because of MRO)

✅ Python resolves this using Method Resolution Order (MRO): D → B → C → A.

To check the MRO of a class:

print(D.mro())

🔹 MRO Output: [D, B, C, A, object]


Polymorphism in Python (OOP)

Polymorphism means "many forms." In Object-Oriented Programming (OOP), polymorphism allows different classes to have methods with the same name but different behaviors.


Why Use Polymorphism?

Increases code flexibility by allowing different objects to be treated the same way.
Simplifies code by using the same method name for different data types or objects.
Supports method overriding and method overloading (limited in Python).

Types of Polymorphism in Python

1️⃣ Method Overriding (Runtime Polymorphism)

  • A child class overrides a method from the parent class to provide a new behavior.

  • The method name remains the same, but the behavior changes in the child class.

class Animal:
    def make_sound(self):
        return "Some generic animal sound"

class Dog(Animal):
    def make_sound(self):  # Overriding method
        return "Bark!"

class Cat(Animal):
    def make_sound(self):  # Overriding method
        return "Meow!"

# Using polymorphism
animals = [Dog(), Cat(), Animal()]

for animal in animals:
    print(animal.make_sound())

🔹 Output:

rustCopyEditBark!
Meow!
Some generic animal sound

The make_sound() method behaves differently based on the object type.


2️⃣ Method Overloading (Compile-Time Polymorphism) – Not Natively Supported in Python

Unlike other languages, Python does not support method overloading in the traditional sense. However, we can achieve similar functionality using default arguments or *args/**kwargs.

class MathOperations:
    def add(self, x, y, z=0):  # Default argument for method overloading
        return x + y + z

obj = MathOperations()
print(obj.add(2, 3))       # Output: 5
print(obj.add(2, 3, 4))    # Output: 9

✅ Here, add() acts like an overloaded function by using a default parameter.


3️⃣ Operator Overloading (Magic Methods)

Python allows operators like +, -, * to work differently for different data types by using special methods (dunder methods).

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __add__(self, other):  # Overloading the `+` operator
        return Point(self.x + other.x, self.y + other.y)

# Creating objects
p1 = Point(2, 3)
p2 = Point(4, 5)
result = p1 + p2  # Uses __add__()

print(f"Result: ({result.x}, {result.y})")  # Output: (6, 8)

The + operator is overloaded to work with custom objects.


4️⃣ Polymorphism with Functions and Classes

Polymorphism works even if the objects belong to different classes, as long as they have the same method names.

class Car:
    def move(self):
        return "The car is driving"

class Boat:
    def move(self):
        return "The boat is sailing"

class Plane:
    def move(self):
        return "The plane is flying"

# Function using polymorphism
def transport_mode(vehicle):
    print(vehicle.move())

# Calling function with different objects
vehicles = [Car(), Boat(), Plane()]
for v in vehicles:
    transport_mode(v)

🔹 Output:

csharpCopyEditThe car is driving
The boat is sailing
The plane is flying

Even though each class is different, they all have a move() method, enabling polymorphism.


Encapsulation in Python (OOP)

Encapsulation is one of the core principles of Object-Oriented Programming (OOP). It is the practice of restricting direct access to certain data within a class and only allowing controlled interaction through methods.


Why Use Encapsulation?

✅ Protects data from accidental modification.
✅ Hides implementation details and exposes only necessary functionality.
✅ Increases security by restricting direct access to variables.
✅ Improves code maintainability by defining controlled ways to interact with data.


Encapsulation in Python

Python implements encapsulation using private and protected members:

  • Public Members → Accessible anywhere.

  • Protected Members (_variable) → Should be accessed only within the class or subclasses (convention, not enforced).

  • Private Members (__variable) → Cannot be accessed directly outside the class (name-mangling applies).


Example: Public, Protected, and Private Members

class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner         # Public attribute
        self._account_type = "Savings"  # Protected attribute
        self.__balance = balance   # Private attribute

    def deposit(self, amount):
        self.__balance += amount
        print(f"Deposited {amount}. New balance: {self.__balance}")

    def withdraw(self, amount):
        if amount <= self.__balance:
            self.__balance -= amount
            print(f"Withdrew {amount}. New balance: {self.__balance}")
        else:
            print("Insufficient funds!")

    def get_balance(self):  # Public method to access private data
        return self.__balance

# Creating an object
account = BankAccount("Alice", 5000)

# Accessing public attribute
print(account.owner)  # ✅ Output: Alice

# Accessing protected attribute (allowed, but not recommended)
print(account._account_type)  # ✅ Output: Savings

# Trying to access private attribute directly (will fail)
# print(account.__balance)  ❌ AttributeError: 'BankAccount' object has no attribute '__balance'

# Accessing private attribute using a method
print(account.get_balance())  # ✅ Output: 5000

# Depositing and withdrawing money
account.deposit(2000)  # ✅ Output: Deposited 2000. New balance: 7000
account.withdraw(1000)  # ✅ Output: Withdrew 1000. New balance: 6000

Name Mangling: Accessing Private Members

Python performs name mangling to protect private members. You can access them using _ClassName__attribute, but this is not recommended.

print(account._BankAccount__balance)  # ✅ Output: 6000 (Avoid doing this!)

This works, but breaks encapsulation. Always use getter methods instead!


Using Getters and Setters (Encapsulation Best Practice)

To properly access and modify private attributes, use getter and setter methods.

class Car:
    def __init__(self, model, price):
        self.model = model
        self.__price = price  # Private variable

    def get_price(self):  # Getter method
        return self.__price

    def set_price(self, new_price):  # Setter method
        if new_price > 0:
            self.__price = new_price
        else:
            print("Invalid price!")

# Creating object
car = Car("Tesla", 50000)

# Accessing private variable using getter
print(car.get_price())  # ✅ Output: 50000

# Modifying private variable using setter
car.set_price(55000)
print(car.get_price())  # ✅ Output: 55000

# Trying to set an invalid price
car.set_price(-10000)  # ❌ Output: Invalid price!

Getters and Setters ensure controlled access to private data.


Using @property for Encapsulation (Pythonic Way)

Instead of manually defining getter and setter methods, Python provides the @property decorator.

class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.__salary = salary  # Private variable

    @property
    def salary(self):  # Getter
        return self.__salary

    @salary.setter
    def salary(self, new_salary):  # Setter
        if new_salary > 0:
            self.__salary = new_salary
        else:
            print("Salary must be positive!")

# Creating an object
emp = Employee("John", 5000)

# Accessing salary using @property
print(emp.salary)  # ✅ Output: 5000

# Modifying salary using @salary.setter
emp.salary = 6000
print(emp.salary)  # ✅ Output: 6000

# Trying to set a negative salary
emp.salary = -2000  # ❌ Output: Salary must be positive!

This is the most Pythonic way to use getters and setters!


Data Hiding in Python (__Private Variables)

Data Hiding is a crucial part of Encapsulation, which ensures that sensitive data is not directly accessible outside the class. This is achieved using private variables in Python.


What Are Private Variables (__variable)?

In Python, private variables are prefixed with double underscores (__). They cannot be accessed directly from outside the class.


Example: Private Variables in Python

class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner          # Public variable
        self.__balance = balance    # Private variable

    def get_balance(self):  # Getter method to access private data
        return self.__balance

    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount
            print(f"Deposited {amount}. New balance: {self.__balance}")
        else:
            print("Invalid deposit amount!")

    def withdraw(self, amount):
        if 0 < amount <= self.__balance:
            self.__balance -= amount
            print(f"Withdrew {amount}. New balance: {self.__balance}")
        else:
            print("Insufficient funds!")

# Creating an object
account = BankAccount("Alice", 5000)

# Accessing public attribute
print(account.owner)  # ✅ Output: Alice

# Trying to access private attribute directly (This will fail)
# print(account.__balance)  ❌ AttributeError: 'BankAccount' object has no attribute '__balance'

# Accessing private variable using a getter method
print(account.get_balance())  # ✅ Output: 5000

Private variables prevent direct access and enforce controlled access through methods.


Python performs name mangling by renaming private variables internally as _ClassName__variable.
This can be used to force access to private variables, but it breaks encapsulation and should be avoided.

print(account._BankAccount__balance)  # ✅ Output: 5000 (Not recommended)

⚠️ Even though this works, you should never access private variables like this! Always use getter methods instead.


Best Practice: Use Getters and Setters to Access Private Data

To properly access and modify private attributes, use getter and setter methods.

class Car:
    def __init__(self, model, price):
        self.model = model
        self.__price = price  # Private variable

    def get_price(self):  # Getter method
        return self.__price

    def set_price(self, new_price):  # Setter method
        if new_price > 0:
            self.__price = new_price
        else:
            print("Invalid price!")

# Creating object
car = Car("Tesla", 50000)

# Accessing private variable using getter
print(car.get_price())  # ✅ Output: 50000

# Modifying private variable using setter
car.set_price(55000)
print(car.get_price())  # ✅ Output: 55000

# Trying to set an invalid price
car.set_price(-10000)  # ❌ Output: Invalid price!

This ensures controlled access and data integrity.


Using @property for Private Variables (Pythonic Way)

Instead of manually defining getter and setter methods, Python provides the @property decorator.

class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.__salary = salary  # Private variable

    @property
    def salary(self):  # Getter
        return self.__salary

    @salary.setter
    def salary(self, new_salary):  # Setter
        if new_salary > 0:
            self.__salary = new_salary
        else:
            print("Salary must be positive!")

# Creating an object
emp = Employee("John", 5000)

# Accessing private variable using @property
print(emp.salary)  # ✅ Output: 5000

# Modifying private variable using @salary.setter
emp.salary = 6000
print(emp.salary)  # ✅ Output: 6000

# Trying to set a negative salary
emp.salary = -2000  # ❌ Output: Salary must be positive!

This is the recommended way to handle private attributes in Python!


Abstraction in Python (OOP)

What is Abstraction?

Abstraction is an OOP principle that hides the implementation details and only exposes essential features of an object. It allows us to define a blueprint for a class without specifying its exact implementation.

In Python, abstraction is implemented using abstract classes and the ABC module.


Why Use Abstraction?

✅ Hides unnecessary details and exposes only essential features.
✅ Provides a blueprint for derived classes without enforcing a specific implementation.
✅ Ensures method implementation in child classes.
✅ Supports maintainability and scalability by enforcing a structure.


Abstract Classes and Methods

In Python, an abstract class is a class that cannot be instantiated and contains one or more abstract methods.

  • Abstract classes are created using ABC (Abstract Base Class) from the abc module.

  • Abstract methods are declared using @abstractmethod.

  • Child classes must override all abstract methods to be instantiated.


Example: Abstract Class with ABC Module

from abc import ABC, abstractmethod

# Abstract class
class Vehicle(ABC):  
    @abstractmethod
    def start_engine(self):  # Abstract method (no implementation)
        pass

    @abstractmethod
    def stop_engine(self):  # Abstract method (no implementation)
        pass

# Concrete class (must implement all abstract methods)
class Car(Vehicle):
    def start_engine(self):
        print("Car engine started.")

    def stop_engine(self):
        print("Car engine stopped.")

# Creating an object
car = Car()
car.start_engine()  # ✅ Output: Car engine started.
car.stop_engine()   # ✅ Output: Car engine stopped.

# Trying to instantiate the abstract class (will fail)
# vehicle = Vehicle()  ❌ TypeError: Can't instantiate abstract class Vehicle

The Vehicle class acts as a blueprint, enforcing start_engine() and stop_engine() methods in child classes.


Abstract Class with Concrete Methods

Abstract classes can also have normal (concrete) methods that subclasses inherit.

from abc import ABC, abstractmethod

class Animal(ABC):
    def eat(self):  # Concrete method (common behavior)
        print("This animal eats food.")

    @abstractmethod
    def make_sound(self):  # Abstract method
        pass

class Dog(Animal):
    def make_sound(self):
        print("Bark!")

class Cat(Animal):
    def make_sound(self):
        print("Meow!")

# Creating objects
dog = Dog()
dog.eat()        # ✅ Output: This animal eats food.
dog.make_sound() # ✅ Output: Bark!

cat = Cat()
cat.make_sound() # ✅ Output: Meow!

Subclasses inherit eat() from the abstract class and must implement make_sound().


Abstract Properties

You can also define abstract properties that must be implemented in subclasses.

from abc import ABC, abstractmethod

class Shape(ABC):
    @property
    @abstractmethod
    def area(self):
        pass

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    @property
    def area(self):
        return 3.14 * self.radius * self.radius

# Creating object
circle = Circle(5)
print(circle.area)  # ✅ Output: 78.5

The area property must be implemented in the child class.


Abstract Class with @staticmethod and @classmethod

Abstract classes can also have static methods and class methods.

from abc import ABC, abstractmethod

class Database(ABC):
    @abstractmethod
    def connect(self):
        pass

    @staticmethod
    @abstractmethod
    def info():
        pass

class MySQLDatabase(Database):
    def connect(self):
        print("Connecting to MySQL database...")

    @staticmethod
    def info():
        print("MySQL is an open-source relational database.")

# Creating object
db = MySQLDatabase()
db.connect()  # ✅ Output: Connecting to MySQL database.
db.info()     # ✅ Output: MySQL is an open-source relational database.

Static methods and class methods can also be abstract!


Magic (Dunder) Methods in Python

Magic methods, also known as dunder (double underscore) methods, are special methods in Python that start and end with double underscores (__). These methods allow objects to define their behavior for built-in Python operations, such as string representation, length, addition, iteration, comparison, and more.


Why Use Magic Methods?

✅ Improve readability by customizing object behavior.
✅ Enable built-in operations like printing (__str__), adding (__add__), and comparing (__eq__).
✅ Support operator overloading, making objects behave like built-in types.


Commonly Used Magic Methods

Here are some frequently used magic methods and their purposes:

Magic MethodPurpose
__init__Constructor (initializes an object)
__str__Returns a user-friendly string representation
__repr__Returns an unambiguous string representation
__len__Returns the length of an object
__add__Enables addition (+) operator overloading
__sub__Enables subtraction (-) operator overloading
__mul__Enables multiplication (*) operator overloading
__truediv__Enables division (/) operator overloading
__eq__Enables equality (==) comparison
__lt__Enables less than (<) comparison
__gt__Enables greater than (>) comparison
__getitem__Enables indexing (obj[index])
__setitem__Enables item assignment (obj[index] = value)
__iter__Makes an object iterable
__next__Retrieves the next item in an iterator

1. __init__ (Constructor)

The __init__ method is called when an object is created.

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

# Creating an object
p = Person("Alice", 25)
print(p.name)  # ✅ Output: Alice
print(p.age)   # ✅ Output: 25

__init__ initializes object attributes.


2. __str__ vs. __repr__ (String Representation)

🔹 __str__ → User-friendly representation

🔹 __repr__ → Developer-friendly representation

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def __str__(self):
        return f"Person(name={self.name}, age={self.age})"  # Readable output

    def __repr__(self):
        return f"Person('{self.name}', {self.age})"  # Debugging output

p = Person("Alice", 25)

print(str(p))   # ✅ Output: Person(name=Alice, age=25)
print(repr(p))  # ✅ Output: Person('Alice', 25)

Use __str__ for users and __repr__ for debugging/logging.

3. __len__ (Length of an Object)

The __len__ method defines the behavior of the len() function.

class Book:
    def __init__(self, title, pages):
        self.title = title
        self.pages = pages

    def __len__(self):
        return self.pages

book = Book("Python Basics", 300)
print(len(book))  # ✅ Output: 300

Use __len__ to define object size.


4. Operator Overloading (__add__, __sub__, etc.)

Python allows objects to overload operators by defining magic methods.

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __add__(self, other):  # Overloading `+`
        return Vector(self.x + other.x, self.y + other.y)

    def __sub__(self, other):  # Overloading `-`
        return Vector(self.x - other.x, self.y - other.y)

    def __str__(self):
        return f"Vector({self.x}, {self.y})"

v1 = Vector(2, 3)
v2 = Vector(4, 5)

print(v1 + v2)  # ✅ Output: Vector(6, 8)
print(v1 - v2)  # ✅ Output: Vector(-2, -2)

Operator overloading makes custom objects behave like built-in types.


5. Comparison Operators (__eq__, __lt__, __gt__)

Define custom comparison logic for objects.

class Student:
    def __init__(self, name, grade):
        self.name = name
        self.grade = grade

    def __eq__(self, other):  # Overloading `==`
        return self.grade == other.grade

    def __lt__(self, other):  # Overloading `<`
        return self.grade < other.grade

    def __gt__(self, other):  # Overloading `>`
        return self.grade > other.grade

s1 = Student("Alice", 90)
s2 = Student("Bob", 85)

print(s1 == s2)  # ✅ Output: False
print(s1 > s2)   # ✅ Output: True
print(s1 < s2)   # ✅ Output: False

Custom comparison logic allows object-based sorting and comparisons.


6. __getitem__, __setitem__ (Indexing & Item Assignment)

Define how objects handle indexing and assignment.

class ShoppingCart:
    def __init__(self):
        self.items = {}

    def __getitem__(self, item):  # Getting an item
        return self.items.get(item, 0)

    def __setitem__(self, item, quantity):  # Setting an item
        self.items[item] = quantity

cart = ShoppingCart()
cart["apple"] = 5  # ✅ Calls `__setitem__`
print(cart["apple"])  # ✅ Calls `__getitem__`, Output: 5

This enables dictionary-like behavior for custom objects.


7. __iter__, __next__ (Iteration)

Define objects that can be looped over.

class Counter:
    def __init__(self, start, end):
        self.current = start
        self.end = end

    def __iter__(self):
        return self  # Returning the iterator object itself

    def __next__(self):
        if self.current >= self.end:
            raise StopIteration  # Stop when end is reached
        self.current += 1
        return self.current - 1

counter = Counter(1, 5)

for num in counter:
    print(num)  # ✅ Output: 1 2 3 4

__iter__ and __next__ enable iteration over custom objects.


Operator Overloading in Python

What is Operator Overloading?

Operator overloading allows us to define how operators (+, -, *, /, etc.) behave for custom objects. By default, these operators work with built-in types like integers and strings, but with magic methods (dunder methods), we can make them work with user-defined classes as well.

For example, + is used for both numeric addition (2 + 3) and string concatenation ("Hello" + "World"). With operator overloading, we can define custom behavior for + and other operators.


Why Use Operator Overloading?

✅ Makes objects behave like built-in types.
✅ Improves code readability.
✅ Allows mathematical operations on custom objects.
✅ Enables comparison between objects.


Magic Methods for Operator Overloading

Python provides magic methods (dunder methods) to define behavior for operators:

OperatorMagic MethodExample
+__add__(self, other)obj1 + obj2
-__sub__(self, other)obj1 - obj2
*__mul__(self, other)obj1 * obj2
/__truediv__(self, other)obj1 / obj2
//__floordiv__(self, other)obj1 // obj2
%__mod__(self, other)obj1 % obj2
**__pow__(self, other)obj1 ** obj2
==__eq__(self, other)obj1 == obj2
!=__ne__(self, other)obj1 != obj2
<__lt__(self, other)obj1 < obj2
>__gt__(self, other)obj1 > obj2
<=__le__(self, other)obj1 <= obj2
>=__ge__(self, other)obj1 >= obj2

1. Overloading + (Addition)

We can overload + by defining the __add__ method.

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __add__(self, other):  # Overloading `+`
        return Vector(self.x + other.x, self.y + other.y)

    def __str__(self):  # For readable output
        return f"Vector({self.x}, {self.y})"

v1 = Vector(2, 3)
v2 = Vector(4, 5)

print(v1 + v2)  # ✅ Output: Vector(6, 8)

The + operator now works with Vector objects.


2. Overloading - (Subtraction)

Similarly, we can overload - by defining __sub__.

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __sub__(self, other):  # Overloading `-`
        return Vector(self.x - other.x, self.y - other.y)

    def __str__(self):
        return f"Vector({self.x}, {self.y})"

v1 = Vector(5, 7)
v2 = Vector(2, 3)

print(v1 - v2)  # ✅ Output: Vector(3, 4)

The - operator now works for our custom class.


3. Overloading * (Multiplication)

We can define how objects multiply each other.

class Product:
    def __init__(self, price):
        self.price = price

    def __mul__(self, quantity):  # Overloading `*`
        return self.price * quantity

p1 = Product(50)
print(p1 * 3)  # ✅ Output: 150

Now, we can multiply a Product object with a number.


4. Overloading / (Division)

We can define how objects divide each other.

class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    def __truediv__(self, other):  # Overloading `/`
        return Temperature(self.celsius / other)

    def __str__(self):
        return f"{self.celsius}°C"

t1 = Temperature(100)
print(t1 / 2)  # ✅ Output: 50.0°C

Now, division works with custom objects.


5. Overloading Comparison Operators (==, <, >)

Define custom comparison behavior for objects.

class Student:
    def __init__(self, name, grade):
        self.name = name
        self.grade = grade

    def __eq__(self, other):  # Overloading `==`
        return self.grade == other.grade

    def __lt__(self, other):  # Overloading `<`
        return self.grade < other.grade

    def __gt__(self, other):  # Overloading `>`
        return self.grade > other.grade

s1 = Student("Alice", 85)
s2 = Student("Bob", 90)

print(s1 == s2)  # ✅ Output: False
print(s1 < s2)   # ✅ Output: True
print(s1 > s2)   # ✅ Output: False

Objects can now be compared like numbers.


6. Overloading [] (Indexing with __getitem__)

Allows objects to behave like lists or dictionaries.

class ShoppingCart:
    def __init__(self):
        self.items = {}

    def __getitem__(self, item):  # Overloading `[]`
        return self.items.get(item, 0)

    def __setitem__(self, item, quantity):  # Setting an item
        self.items[item] = quantity

cart = ShoppingCart()
cart["apple"] = 5  # ✅ Calls `__setitem__`
print(cart["apple"])  # ✅ Calls `__getitem__`, Output: 5

Now, ShoppingCart behaves like a dictionary!


Method Resolution Order (MRO) in Python

What is MRO?

Method Resolution Order (MRO) is the order in which Python looks for a method in a class hierarchy. It determines which method is called when a method is invoked on an instance, especially in the case of inheritance and multiple inheritance.

Python uses the C3 Linearization (also called the "C3 Algorithm") to determine MRO, ensuring that:
A child class is checked before parent classes.
A method is called only once in cases of multiple inheritance.
The order remains consistent and predictable.

You can check the MRO of a class using:

ClassName.__mro__   # Returns a tuple showing MRO
help(ClassName)      # Shows MRO details

Example: Simple MRO in Single Inheritance

In a single inheritance scenario, the MRO follows a top-down approach from child to parent.

class A:
    def show(self):
        print("A")

class B(A):
    def show(self):
        print("B")

obj = B()
obj.show()  # ✅ Output: B
print(B.__mro__)  # ✅ Output: (<class '__main__.B'>, <class '__main__.A'>, <class 'object'>)

Since show() is found in B, Python doesn’t look in A.


Example: MRO in Multiple Inheritance

In multiple inheritance, MRO ensures a consistent and conflict-free method resolution.

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):  # Multiple Inheritance
    pass

obj = D()
obj.show()  # ✅ Output: B
print(D.__mro__)  
# ✅ Output: (<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)

MRO follows D → B → C → A → object order, meaning Python looks in B first, then C, then A.


MRO Using super()

The super() function follows MRO to call methods in a parent class.

class A:
    def show(self):
        print("A")

class B(A):
    def show(self):
        super().show()  # Calls A's show()
        print("B")

obj = B()
obj.show()  
# ✅ Output:
# A
# B

super().show() follows MRO and calls A.show() first.


Diamond Problem & MRO

The Diamond Problem occurs when a class inherits from two classes that share a common ancestor.

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):  # Multiple Inheritance
    pass

obj = D()
obj.show()  # ✅ Output: B
print(D.__mro__)  
# ✅ Output: (<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)

MRO ensures A.show() is called only once, avoiding redundancy.

mro() Method

Instead of using __mro__, you can call mro() method:

print(D.mro())
# ✅ Output: [<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>]